sloptimize 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +84 -7
- package/bin/sloptimize.mjs +28 -2
- package/docs/INTEGRATION.md +55 -0
- package/docs/SPEC.md +2 -1
- package/docs/USAGE.md +25 -0
- package/mcp/server.mjs +17 -3
- package/package.json +7 -4
- package/skills/install/SKILL.md +2 -1
- package/src/cloud-client.js +29 -0
- package/src/cloud-sink.js +159 -0
- package/src/errors.js +67 -0
- package/src/footprint.js +67 -2
- package/src/history.js +3 -0
- package/src/index.js +2 -0
- package/src/node/index.js +164 -0
- package/src/node/profiler.js +63 -0
- package/src/recorder.js +17 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sloptimize",
|
|
3
3
|
"displayName": "sloptimize",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"description": "The agent-native profiler for browser games: always-on incident recording, zero-setup attach with file:line attribution, budgets with exit codes — the agent's senses and ruler for performance work.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": ["profiler", "three.js", "webgpu", "performance", "draw-calls"]
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# sloptimize
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/sloptimize)
|
|
4
|
+
|
|
3
5
|
sloptimize optimizes your game's rendering performance by finding the
|
|
4
6
|
bottlenecks and reporting them to Claude Code to fix — all while you just play
|
|
5
7
|
the game. No action is required on your end.
|
|
@@ -64,12 +66,18 @@ and `sloptimize history` read that ledger back.
|
|
|
64
66
|
|
|
65
67
|
## Install
|
|
66
68
|
|
|
69
|
+
Published on npm as [`sloptimize`](https://www.npmjs.com/package/sloptimize).
|
|
70
|
+
Node 22+.
|
|
71
|
+
|
|
67
72
|
```bash
|
|
68
|
-
npm i -D sloptimize
|
|
69
|
-
#
|
|
70
|
-
# or from a checkout: node sloptimize/bin/sloptimize.mjs … (bare Node, no install)
|
|
73
|
+
npm i -D sloptimize # in your game repo (recommended)
|
|
74
|
+
npx sloptimize --version # confirm: prints the installed version
|
|
71
75
|
```
|
|
72
76
|
|
|
77
|
+
No install at all for a one-off: `npx sloptimize attach --launch http://localhost:3000`.
|
|
78
|
+
Working from a git checkout instead? `node /path/to/sloptimize/bin/sloptimize.mjs …`
|
|
79
|
+
runs on bare Node.
|
|
80
|
+
|
|
73
81
|
Zero dependencies, no postinstall, no supply chain — npm is delivery only.
|
|
74
82
|
|
|
75
83
|
## Quickest start: zero integration (tier 0)
|
|
@@ -164,14 +172,17 @@ top where the engine grants scene access — see `docs/SPEC.md` §4.
|
|
|
164
172
|
|
|
165
173
|
## Claude Code integration — the whole point
|
|
166
174
|
|
|
167
|
-
|
|
175
|
+
The npm package IS a Claude Code plugin — skill, prompt hook, and MCP server
|
|
176
|
+
ship inside it. After `npm i -D sloptimize`, point Claude at it:
|
|
168
177
|
|
|
169
178
|
```bash
|
|
170
|
-
claude --plugin-dir node_modules/sloptimize
|
|
171
|
-
claude --plugin-dir /path/to/sloptimize # from a checkout
|
|
172
|
-
# or via marketplace: /plugin marketplace add m0dE/sloptimize && /plugin install sloptimize
|
|
179
|
+
claude --plugin-dir node_modules/sloptimize
|
|
173
180
|
```
|
|
174
181
|
|
|
182
|
+
Alternatives: `claude --plugin-dir /path/to/sloptimize` from a git checkout,
|
|
183
|
+
or via the marketplace:
|
|
184
|
+
`/plugin marketplace add m0dE/sloptimize` then `/plugin install sloptimize`.
|
|
185
|
+
|
|
175
186
|
Then let the agent wire your game: `/sloptimize:install` walks it through
|
|
176
187
|
the tier-1 integration (runtime, sink, budgets, hooks) and refuses to call
|
|
177
188
|
itself done until the feed is proven live end-to-end.
|
|
@@ -192,6 +203,72 @@ prompt needed), arm `sloptimize watch` as a session Monitor — one line, in
|
|
|
192
203
|
`docs/INTEGRATION.md` §5. Wire it into a `SessionStart` hook and every
|
|
193
204
|
session arms it by itself.
|
|
194
205
|
|
|
206
|
+
## Cloud (paid, invite-only)
|
|
207
|
+
|
|
208
|
+
Everything above is local: one machine's `.sloptimize/` directory, read by
|
|
209
|
+
that machine's shell and that machine's Claude Code session. sloptimize
|
|
210
|
+
cloud is a separate, optional, invite-only service that widens the same
|
|
211
|
+
catalogue to **every player, every build** — not just the one in front of
|
|
212
|
+
you: 24h/7d/30d or any custom range, client incidents and server incidents
|
|
213
|
+
(`sloptimize/node`) folded into the same footprint identity, plus uncaught
|
|
214
|
+
errors (`sloptimize/errors`) as their own incident kind. The local product
|
|
215
|
+
stays the default story — nothing below changes what a project with no
|
|
216
|
+
cloud key does.
|
|
217
|
+
|
|
218
|
+
Where the endpoint and key live: your project's settings page on the
|
|
219
|
+
service (there is no public hostname to document here — sloptimize cloud
|
|
220
|
+
is invite-only, and the endpoint you use is whatever that page shows you).
|
|
221
|
+
It hands you three snippets:
|
|
222
|
+
|
|
223
|
+
```js
|
|
224
|
+
// browser: the cloud sink is a TEE beside your existing drain, never instead
|
|
225
|
+
// of it — errors ride the same recorder as hitches, so one drain feeds both
|
|
226
|
+
import { createRecorder, createErrorMonitor, createCloudSink } from 'sloptimize';
|
|
227
|
+
const rec = createRecorder({ budgetFrameMs: 16.7 });
|
|
228
|
+
createErrorMonitor(rec);
|
|
229
|
+
const cloud = createCloudSink({ key: '<publishable key from settings>', endpoint: '<endpoint from settings>', build });
|
|
230
|
+
// in the ~2s drain you already have (docs/INTEGRATION.md §1):
|
|
231
|
+
const batch = rec.drainRecords();
|
|
232
|
+
post('records', batch); // unchanged: .sloptimize/perf.jsonl, still the source of truth
|
|
233
|
+
cloud.enqueue(batch); // the same records, teed to the cloud sink's own queue
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
(The `sources: [rec]` option exists only for a host with no file sink at all:
|
|
237
|
+
the sink drains those sources itself, so anything it takes never reaches your
|
|
238
|
+
own `drainRecords()`.)
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
// game server (Node): ticks, event-loop stalls, and uncaught errors
|
|
242
|
+
import { createServerRuntime } from 'sloptimize/node';
|
|
243
|
+
const server = createServerRuntime({ key: '<secret key from settings>', endpoint: '<endpoint from settings>', build });
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The server runtime registers `uncaughtExceptionMonitor` only, so it observes
|
|
247
|
+
a crash without ever becoming part of the crash path. One consequence worth
|
|
248
|
+
knowing: under `--unhandled-rejections=warn` or `none`, unhandled rejections
|
|
249
|
+
are **not** captured — that event sees them only in Node's default `throw`
|
|
250
|
+
mode, and listening to `unhandledRejection` instead would suppress the throw
|
|
251
|
+
your process relies on.
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
# CLI: read the cloud catalogue instead of this machine's ledger
|
|
255
|
+
export SLOPTIMIZE_KEY=<secret key from settings> SLOPTIMIZE_ENDPOINT=<endpoint from settings>
|
|
256
|
+
npx sloptimize issues --cloud --preset 7d
|
|
257
|
+
npx sloptimize fix --title "…" --push # records locally, then pushes
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Honesty is the whole pitch: a dropped-locally count rides every batch the
|
|
261
|
+
sink sends, so the dashboard's numbers say what they could not see rather
|
|
262
|
+
than pretending nothing was lost.
|
|
263
|
+
|
|
264
|
+
Two kinds of key, and the difference matters. The **publishable** key is
|
|
265
|
+
public and write-only (it can post incidents, never read anyone else's), so
|
|
266
|
+
shipping it in a client bundle is the intended use, not a leak — that is the
|
|
267
|
+
key in the browser snippet above. The **secret** key is the one the server
|
|
268
|
+
runtime, the CLI (`SLOPTIMIZE_KEY`) and the MCP server use: it reads your
|
|
269
|
+
whole catalogue (`/v1/issues`) and writes fixes (`/v1/fixes`). A secret key
|
|
270
|
+
never goes in a client bundle.
|
|
271
|
+
|
|
195
272
|
## Budgets: "fast enough" as an exit code
|
|
196
273
|
|
|
197
274
|
`.sloptimize/budgets.json` (the one file a human reviews):
|
package/bin/sloptimize.mjs
CHANGED
|
@@ -88,8 +88,24 @@ if (cmd === 'issues') {
|
|
|
88
88
|
// The issue catalogue (SPEC §3.7): every incident type grouped by
|
|
89
89
|
// footprint, with occurrences, first/last, builds, worst, and the fixes
|
|
90
90
|
// applied to it. `--from/--to` scope the count; `--all` includes robots.
|
|
91
|
-
const { buildIssues, agoText } = await import('../src/history.js');
|
|
92
91
|
const get = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
|
|
92
|
+
const { buildIssues, agoText } = await import('../src/history.js');
|
|
93
|
+
if (args.includes('--cloud')) {
|
|
94
|
+
// The cloud catalogue (SPEC cloud §8.4): every player, every build, not
|
|
95
|
+
// just this machine's ledger. Configuration is explicit — a missing key
|
|
96
|
+
// or endpoint is said, never guessed.
|
|
97
|
+
const { cloudConfig, fetchIssues } = await import('../src/cloud-client.js');
|
|
98
|
+
const cfg = cloudConfig(process.env, args);
|
|
99
|
+
if (!cfg) { console.error('sloptimize issues --cloud: set SLOPTIMIZE_KEY and SLOPTIMIZE_ENDPOINT (or --key/--endpoint)'); process.exit(2); }
|
|
100
|
+
let rows;
|
|
101
|
+
try { rows = await fetchIssues(cfg, { preset: get('--preset'), from: get('--from'), to: get('--to'), source: get('--source'), kind: get('--kind') }); }
|
|
102
|
+
catch (e) { console.error(`sloptimize issues --cloud: ${e.message}`); process.exit(4); }
|
|
103
|
+
if (json) { out(rows); process.exit(0); }
|
|
104
|
+
if (rows.length === 0) { console.log('no incidents in this range on the cloud catalogue'); process.exit(4); }
|
|
105
|
+
console.log(`cloud ${cfg.endpoint} · ${get('--preset') ?? (get('--from') ? 'custom' : '24h')} · ${rows.length} footprints`);
|
|
106
|
+
for (const i of rows) console.log(`${i.glyph} fp=${i.id} ×${String(i.count).padEnd(5)} ${i.label.padEnd(44)} [${i.phase}] ${i.source} last ${agoText(i.lastAgoMs).padEnd(8)} first ${i.first.slice(0, 16)} builds ${i.builds.length}${i.fixCount ? ` fixes ${i.fixCount}` : ''}`);
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
93
109
|
const issues = buildIssues(readJsonl('perf.jsonl', Infinity), {
|
|
94
110
|
fixes: readJsonl('fixes.jsonl', Infinity), from: get('--from'), to: get('--to'), includeAutomated: args.includes('--all'),
|
|
95
111
|
});
|
|
@@ -161,6 +177,8 @@ if (cmd === 'doctor') {
|
|
|
161
177
|
console.log(' stated limits: no per-draw GPU timing; bisection ranks, never sums; workload repro not trajectory repro;');
|
|
162
178
|
console.log(' gpu:* instruments fire only under a real WebGPU backend — a WebGL2-fallback session reads them as zeros, honestly;');
|
|
163
179
|
console.log(' bench/gate (M3) not built yet in this install — verify fixes with counters (exact grade) + real-hardware sessions.');
|
|
180
|
+
const cfg = (await import('../src/cloud-client.js')).cloudConfig(process.env, args);
|
|
181
|
+
console.log(cfg ? ` cloud: configured (${cfg.endpoint})` : ' cloud: not configured (SLOPTIMIZE_KEY, SLOPTIMIZE_ENDPOINT)');
|
|
164
182
|
process.exit(0);
|
|
165
183
|
}
|
|
166
184
|
|
|
@@ -300,6 +318,14 @@ if (cmd === 'history' || cmd === 'fix') {
|
|
|
300
318
|
const { appendFileSync, mkdirSync } = await import('node:fs');
|
|
301
319
|
mkdirSync(DIR, { recursive: true });
|
|
302
320
|
appendFileSync(join(DIR, 'fixes.jsonl'), JSON.stringify(fix) + '\n');
|
|
321
|
+
if (args.includes('--push')) {
|
|
322
|
+
// The local ledger is the source of truth — a push failure is
|
|
323
|
+
// reported but never turns a recorded fix into a failed command.
|
|
324
|
+
const { cloudConfig, pushFix } = await import('../src/cloud-client.js');
|
|
325
|
+
const cfg = cloudConfig(process.env, args);
|
|
326
|
+
if (!cfg) console.error('push skipped: set SLOPTIMIZE_KEY and SLOPTIMIZE_ENDPOINT');
|
|
327
|
+
else { try { await pushFix(cfg, fix); console.log('pushed to cloud'); } catch (e) { console.error(`push failed: ${e.message}`); } }
|
|
328
|
+
}
|
|
303
329
|
out(fix, `fix recorded: ${fix.title}${fix.commit ? ` (${fix.commit})` : ''}\n before ${fix.before.build ?? fix.before.from}: ${line(fix.before)}\n after ${fix.after.build ?? fix.after.from}: ${line(fix.after)}`);
|
|
304
330
|
process.exit(0);
|
|
305
331
|
}
|
|
@@ -342,5 +368,5 @@ if (cmd === 'attach') {
|
|
|
342
368
|
await new Promise(() => {});
|
|
343
369
|
}
|
|
344
370
|
|
|
345
|
-
console.log('usage: sloptimize <report|issues|check|census|history|fix|doctor|hook-status|watch|attach> [--json] [--dir <path>]... [--counters-only] [--interval <s>] [--min-hitch-ms N] [--launch <url>] [--port N] [--headless]\n sloptimize fix --title "…" [--issue "…"] [--solution "…"] [--commit sha] [--files a,b] [--footprints id,id] [--before <build|ISO..ISO>] [--after <build|ISO..ISO>]\n sloptimize issues [--json] [--from ISO] [--to ISO] [--fp <id>] [--all]');
|
|
371
|
+
console.log('usage: sloptimize <report|issues|check|census|history|fix|doctor|hook-status|watch|attach> [--json] [--dir <path>]... [--counters-only] [--interval <s>] [--min-hitch-ms N] [--launch <url>] [--port N] [--headless]\n sloptimize fix --title "…" [--issue "…"] [--solution "…"] [--commit sha] [--files a,b] [--footprints id,id] [--before <build|ISO..ISO>] [--after <build|ISO..ISO>] [--push]\n sloptimize issues [--json] [--from ISO] [--to ISO] [--fp <id>] [--all] [--cloud [--preset 24h|7d|30d] [--source s] [--kind k] [--key k] [--endpoint url]]');
|
|
346
372
|
process.exit(2);
|
package/docs/INTEGRATION.md
CHANGED
|
@@ -164,6 +164,61 @@ panel.open();
|
|
|
164
164
|
Flush cadence: post `profile` every ~2s, drain records with it.
|
|
165
165
|
Gitignore `.sloptimize/*` except `budgets.json`.
|
|
166
166
|
|
|
167
|
+
### Cloud sink (optional, paid, invite-only)
|
|
168
|
+
|
|
169
|
+
The dev-endpoint sink above stays the default: it is what makes the files
|
|
170
|
+
useful with nobody watching. sloptimize cloud is a separate, additive tee —
|
|
171
|
+
never a replacement — that ships the same records to a service so the
|
|
172
|
+
catalogue spans every player and build, not just this machine. Run it
|
|
173
|
+
BESIDE the local `post()`, never instead of it:
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
import { createCloudSink } from 'sloptimize/cloud';
|
|
177
|
+
const cloud = createCloudSink({ key: '<publishable key>', endpoint: '<endpoint>', build }); // publishable: safe in the client bundle
|
|
178
|
+
// wherever the local sink drains and posts:
|
|
179
|
+
const batch = [...rec.drainRecords(), ...motion.drainRecords()];
|
|
180
|
+
post('records', batch); // unchanged: the local ledger, still the source of truth
|
|
181
|
+
cloud.enqueue(batch); // same records, teed to the cloud sink's own queue and flush timer
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
`enqueue(records)` just appends to the sink's internal queue (capped at
|
|
185
|
+
`maxQueue`, oldest dropped and counted honestly) — it does not fetch or
|
|
186
|
+
flush itself; the sink's own timer (and `pagehide`/`visibilitychange`) drain
|
|
187
|
+
and post it on the usual backoff. Pair it with `createErrorMonitor(rec)` —
|
|
188
|
+
the SAME recorder from §1, not a bare call — so uncaught client errors land
|
|
189
|
+
in `rec` via `recorder.emit()` and ride `rec.drainRecords()` into `batch`
|
|
190
|
+
above like any hitch, with no separate wiring to the cloud sink needed.
|
|
191
|
+
|
|
192
|
+
### Game server (optional, paid, invite-only)
|
|
193
|
+
|
|
194
|
+
The server side of the same catalogue: ticks that overran their budget,
|
|
195
|
+
event-loop stalls the runtime itself measured, and uncaught errors — each
|
|
196
|
+
attributed by a sampling profiler and shipped through the same cloud sink
|
|
197
|
+
the browser uses.
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
import { createServerRuntime } from 'sloptimize/node';
|
|
201
|
+
const server = createServerRuntime({ key: '<secret key>', endpoint: '<endpoint>', build, tickBudgetMs: 16 });
|
|
202
|
+
|
|
203
|
+
function gameLoop() {
|
|
204
|
+
server.tick(() => { // wraps one tick; records a server-hitch if it overran tickBudgetMs
|
|
205
|
+
// … the game's own tick work …
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The key here is a SECRET key (`server-*` record types and the read routes
|
|
211
|
+
are secret-only) — it never goes in a client bundle.
|
|
212
|
+
|
|
213
|
+
`createServerRuntime` registers `uncaughtExceptionMonitor` only (never
|
|
214
|
+
`uncaughtException`/`unhandledRejection`) — it observes a crash, it never
|
|
215
|
+
becomes part of the crash path. That also means that under
|
|
216
|
+
`--unhandled-rejections=warn` or `none`, unhandled rejections are NOT
|
|
217
|
+
captured: `uncaughtExceptionMonitor` sees them only in Node's default
|
|
218
|
+
`throw` mode. Call `await server.close()` on shutdown to
|
|
219
|
+
flush the queue; a `beforeExit` hook already races a best-effort flush so a
|
|
220
|
+
clean exit does not lose the last batch.
|
|
221
|
+
|
|
167
222
|
## 3. The CLI (the agent's shell surface)
|
|
168
223
|
|
|
169
224
|
`sloptimize report|check|census|doctor --dir <game>/.sloptimize` — no
|
package/docs/SPEC.md
CHANGED
|
@@ -359,7 +359,8 @@ fixes applied to it, or the exact command that would record one.
|
|
|
359
359
|
|
|
360
360
|
Cloud path: the footprint is computed by the writer, so a service that
|
|
361
361
|
ingests many clients' records dedupes on `footprint.id` from day one; the
|
|
362
|
-
fold is the same code.
|
|
362
|
+
fold is the same code. The service is specified in the sloptimize-cloud repo
|
|
363
|
+
(`docs/superpowers/specs/2026-09-02-sloptimize-cloud-design.md`).
|
|
363
364
|
|
|
364
365
|
---
|
|
365
366
|
|
package/docs/USAGE.md
CHANGED
|
@@ -160,6 +160,31 @@ npx sloptimize history --dir <game>/.sloptimize # p95/calls/hitches over time,
|
|
|
160
160
|
npx sloptimize doctor --dir <game>/.sloptimize # what is wired/degraded
|
|
161
161
|
```
|
|
162
162
|
|
|
163
|
+
## The cloud catalogue (optional, paid, invite-only)
|
|
164
|
+
|
|
165
|
+
With a project on sloptimize cloud, `SLOPTIMIZE_KEY` and `SLOPTIMIZE_ENDPOINT`
|
|
166
|
+
(or `--key`/`--endpoint`) point the same CLI at every player's ledger
|
|
167
|
+
instead of just this machine's:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
export SLOPTIMIZE_KEY=<secret key from the settings page> # secret, not publishable: /v1/issues is secret-only
|
|
171
|
+
export SLOPTIMIZE_ENDPOINT=<endpoint from the settings page>
|
|
172
|
+
npx sloptimize issues --cloud # last 24h across every player/build, by default
|
|
173
|
+
npx sloptimize issues --cloud --preset 7d # or 30d
|
|
174
|
+
npx sloptimize issues --cloud --from <ISO> --to <ISO> --source client --kind hitch
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Unconfigured, it exits 2 and names the missing variable rather than
|
|
178
|
+
silently falling back to the local ledger; a request that fails once
|
|
179
|
+
configured (bad key, unreachable endpoint) exits 4. `sloptimize doctor`
|
|
180
|
+
reports which state you're in (`cloud: configured (<endpoint>)` or
|
|
181
|
+
`cloud: not configured (...)`).
|
|
182
|
+
|
|
183
|
+
`sloptimize fix --push` records the fix locally exactly as `sloptimize fix`
|
|
184
|
+
always has — the local ledger stays the source of truth — and then also
|
|
185
|
+
POSTs it to the cloud service; a push failure prints `push failed: <reason>`
|
|
186
|
+
but the command still exits 0, since the fix was recorded either way.
|
|
187
|
+
|
|
163
188
|
## Recording a fix (the agent's last step)
|
|
164
189
|
|
|
165
190
|
Once a fix is verified — the new build is live and the ledger has evidence
|
package/mcp/server.mjs
CHANGED
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
// wake is not a contract this host documents; when it becomes one, the
|
|
14
14
|
// watcher moves here (SPEC-attach §2, delivery edge).
|
|
15
15
|
import { readFileSync, existsSync } from 'node:fs';
|
|
16
|
-
import { join } from 'node:path';
|
|
16
|
+
import { join, dirname } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
17
18
|
import { createInterface } from 'node:readline';
|
|
18
19
|
|
|
20
|
+
const PKG_VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')).version;
|
|
21
|
+
|
|
19
22
|
const DIR = () => join(process.cwd(), '.sloptimize');
|
|
20
23
|
let attachSession = null;
|
|
21
24
|
|
|
@@ -27,7 +30,9 @@ const TOOLS = [
|
|
|
27
30
|
{ name: 'get_history', description: 'The deployment’s timeline folded from perf.jsonl: time buckets (frame p95, draw calls, hitch spikes, build), one measured window per build, and the fix ledger (fixes.jsonl) — the before/after evidence behind every recorded fix.',
|
|
28
31
|
inputSchema: { type: 'object', properties: { buckets: { type: 'number', description: 'time slices (default 24)' } } } },
|
|
29
32
|
{ name: 'get_issues', description: 'The issue catalogue (SPEC §3.7): every incident type on the ledger grouped by FOOTPRINT — the identity of a cause (type, phase, verdict, site, the game’s situation), never its time — with occurrences, first/last seen, builds, worst, the last verdict, and the fixes applied to it. Read this before proposing a fix: an issue with a fix already recorded is not new.',
|
|
30
|
-
inputSchema: { type: 'object', properties: { fp: { type: 'string', description: 'one footprint id' }, from: { type: 'string', description: 'ISO lower bound' }, to: { type: 'string', description: 'ISO upper bound' }, includeAutomated: { type: 'boolean', description: 'count robots’ sessions too (default false)' }, limit: { type: 'number', description: 'max rows (default 50)' }
|
|
33
|
+
inputSchema: { type: 'object', properties: { fp: { type: 'string', description: 'one footprint id' }, from: { type: 'string', description: 'ISO lower bound' }, to: { type: 'string', description: 'ISO upper bound' }, includeAutomated: { type: 'boolean', description: 'count robots’ sessions too (default false)' }, limit: { type: 'number', description: 'max rows (default 50)' },
|
|
34
|
+
cloud: { type: 'boolean', description: 'read the cloud catalogue (every player, every build) instead of this machine\'s ledger — requires SLOPTIMIZE_KEY/SLOPTIMIZE_ENDPOINT' },
|
|
35
|
+
preset: { type: 'string', description: 'cloud only: 24h | 7d | 30d' }, source: { type: 'string', description: 'cloud only: filter by source (client|server)' }, kind: { type: 'string', description: 'cloud only: filter by incident kind' } } } },
|
|
31
36
|
{ name: 'record_fix', description: 'Append a fix report to .sloptimize/fixes.jsonl: title, issue, solution, commit, the FOOTPRINTS it addresses (from get_issues — this is how the Issues tab shows which fixes were applied to an issue), and MEASURED before/after windows of the ledger (default: the previous build vs the latest build with evidence; or name a build / an <ISO>..<ISO> range). Call this after verifying a perf fix — never with numbers of your own.',
|
|
32
37
|
inputSchema: { type: 'object', properties: { title: { type: 'string' }, issue: { type: 'string' }, solution: { type: 'string' }, commit: { type: 'string' },
|
|
33
38
|
files: { type: 'array', items: { type: 'string' } }, footprints: { type: 'array', items: { type: 'string' }, description: 'footprint ids this fix addresses' },
|
|
@@ -69,6 +74,15 @@ async function callTool(name, args = {}) {
|
|
|
69
74
|
return buildHistory(readJsonl('perf.jsonl', Infinity), { fixes: readJsonl('fixes.jsonl', Infinity), buckets: args.buckets ?? 24 });
|
|
70
75
|
}
|
|
71
76
|
if (name === 'get_issues') {
|
|
77
|
+
if (args.cloud === true) {
|
|
78
|
+
const { cloudConfig, fetchIssues } = await import('../src/cloud-client.js');
|
|
79
|
+
const cfg = cloudConfig(process.env, []);
|
|
80
|
+
if (!cfg) return { error: 'cloud not configured: set SLOPTIMIZE_KEY and SLOPTIMIZE_ENDPOINT' };
|
|
81
|
+
try {
|
|
82
|
+
const rows = await fetchIssues(cfg, { preset: args.preset, from: args.from, to: args.to, source: args.source, kind: args.kind });
|
|
83
|
+
return { source: 'cloud', endpoint: cfg.endpoint, footprints: rows.length, occurrences: rows.reduce((n, i) => n + i.count, 0), issues: args.fp ? rows.filter((i) => i.id === args.fp) : rows.slice(0, args.limit ?? 50) };
|
|
84
|
+
} catch (e) { return { error: e.message }; }
|
|
85
|
+
}
|
|
72
86
|
const { buildIssues } = await import('../src/history.js');
|
|
73
87
|
const issues = buildIssues(readJsonl('perf.jsonl', Infinity), { fixes: readJsonl('fixes.jsonl', Infinity), from: args.from, to: args.to, includeAutomated: args.includeAutomated === true });
|
|
74
88
|
const rows = args.fp ? issues.filter((i) => i.id === args.fp) : issues.slice(0, args.limit ?? 50);
|
|
@@ -111,7 +125,7 @@ rl.on('line', (line) => {
|
|
|
111
125
|
try {
|
|
112
126
|
if (msg.method === 'initialize') {
|
|
113
127
|
reply(msg.id, { protocolVersion: msg.params?.protocolVersion ?? '2024-11-05',
|
|
114
|
-
capabilities: { tools: {} }, serverInfo: { name: 'sloptimize', version:
|
|
128
|
+
capabilities: { tools: {} }, serverInfo: { name: 'sloptimize', version: PKG_VERSION } });
|
|
115
129
|
} else if (msg.method === 'tools/list') {
|
|
116
130
|
reply(msg.id, { tools: TOOLS });
|
|
117
131
|
} else if (msg.method === 'tools/call') {
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sloptimize",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The agent-native profiler for three.js games: an always-on flight recorder, per-entity cost attribution, and a deterministic bench — so a coding agent can measure, attribute, and verify instead of guessing.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"engines": {
|
|
8
|
-
"node": ">=
|
|
8
|
+
"node": ">=22"
|
|
9
9
|
},
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
"exports": {
|
|
22
22
|
".": "./src/index.js",
|
|
23
23
|
"./history": "./src/history.js",
|
|
24
|
-
"./panel": "./src/panel.js"
|
|
24
|
+
"./panel": "./src/panel.js",
|
|
25
|
+
"./cloud": "./src/cloud-sink.js",
|
|
26
|
+
"./errors": "./src/errors.js",
|
|
27
|
+
"./node": "./src/node/index.js"
|
|
25
28
|
},
|
|
26
29
|
"files": [
|
|
27
30
|
"bin",
|
|
@@ -29,7 +32,7 @@
|
|
|
29
32
|
"skills",
|
|
30
33
|
"hooks",
|
|
31
34
|
"mcp",
|
|
32
|
-
"docs",
|
|
35
|
+
"docs/*.md",
|
|
33
36
|
".claude-plugin",
|
|
34
37
|
".mcp.json",
|
|
35
38
|
"LICENSE",
|
package/skills/install/SKILL.md
CHANGED
|
@@ -16,7 +16,8 @@ not report success until step 7 shows your test keyframe in
|
|
|
16
16
|
|
|
17
17
|
## 1. Locate the package and the game's shape
|
|
18
18
|
|
|
19
|
-
- Find sloptimize: `node_modules/sloptimize` (npm) or a
|
|
19
|
+
- Find sloptimize: `node_modules/sloptimize` (from `npm i -D sloptimize`) or a
|
|
20
|
+
sibling checkout. If neither exists, install it from npm first.
|
|
20
21
|
- Identify: the render loop's stats site (wherever `renderer.info` is
|
|
21
22
|
already read), the dev-server topology (vite? own server? esbuild?), and
|
|
22
23
|
the dev-only switch the project already uses for debug endpoints.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// cloud-client.js — read side of sloptimize cloud (SPEC cloud §8.4)
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Read side of sloptimize cloud for the CLI and MCP: the catalogue over every
|
|
5
|
+
// player, not just this machine's ledger. Configuration is explicit — a key
|
|
6
|
+
// and an endpoint — and a missing one is said, never guessed.
|
|
7
|
+
export function cloudConfig(env = process.env, args = []) {
|
|
8
|
+
const flag = (f) => { const i = args.indexOf(f); return i >= 0 ? args[i + 1] : undefined; };
|
|
9
|
+
const key = flag('--key') ?? env.SLOPTIMIZE_KEY;
|
|
10
|
+
const endpoint = (flag('--endpoint') ?? env.SLOPTIMIZE_ENDPOINT ?? '').replace(/\/+$/, '');
|
|
11
|
+
return key && endpoint ? { key, endpoint } : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function fetchIssues(cfg, q = {}, fetchImpl = globalThis.fetch, now = Date.now) {
|
|
15
|
+
const u = new URL(`${cfg.endpoint}/v1/issues`);
|
|
16
|
+
for (const k of ['preset', 'from', 'to', 'source', 'kind']) if (q[k]) u.searchParams.set(k, q[k]);
|
|
17
|
+
const res = await fetchImpl(u.toString(), { headers: { authorization: `Bearer ${cfg.key}` } });
|
|
18
|
+
if (!res.ok) throw new Error(`cloud ${res.status}: ${(await res.json().catch(() => ({}))).error ?? 'request failed'}`);
|
|
19
|
+
const rows = await res.json();
|
|
20
|
+
const t = now();
|
|
21
|
+
return rows.map((r) => ({ id: r.id, key: r.key, type: r.kind, glyph: r.glyph, label: r.label, phase: r.phase, ctx: r.ctx, source: r.source,
|
|
22
|
+
count: r.count, first: r.firstSeen, last: r.lastSeen, lastAgoMs: Math.max(0, t - Date.parse(r.lastSeen)), builds: r.builds ?? [], fixCount: r.fixes ?? 0, fixes: [], daily: r.daily ?? [], exact: r.exact }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function pushFix(cfg, fix, fetchImpl = globalThis.fetch) {
|
|
26
|
+
const res = await fetchImpl(`${cfg.endpoint}/v1/fixes`, { method: 'POST', headers: { authorization: `Bearer ${cfg.key}`, 'content-type': 'application/json' }, body: JSON.stringify(fix) });
|
|
27
|
+
if (!res.ok) throw new Error(`cloud ${res.status}: ${(await res.json().catch(() => ({}))).error ?? 'request failed'}`);
|
|
28
|
+
return res.json();
|
|
29
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// cloud-sink.js — ship records to sloptimize cloud (SPEC cloud §8.2)
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Runs BESIDE the file sink, never instead of it. Drains every source on a
|
|
5
|
+
// timer, posts batches with the publishable key, backs off on 429/5xx, caps
|
|
6
|
+
// its queue, and tells the service how many it had to drop locally so the
|
|
7
|
+
// dashboard's "dropped" column is honest. Never throws into the host.
|
|
8
|
+
const BACKOFF_MS = [5000, 30000, 120000, 300000];
|
|
9
|
+
|
|
10
|
+
export function createCloudSink(opts = {}) {
|
|
11
|
+
if (!opts.key) throw new Error('createCloudSink: key is required');
|
|
12
|
+
if (!opts.endpoint) throw new Error('createCloudSink: endpoint is required');
|
|
13
|
+
const { key, endpoint, build } = opts;
|
|
14
|
+
const sources = opts.sources ?? [];
|
|
15
|
+
const flushMs = opts.flushMs ?? 5000, maxBatch = opts.maxBatch ?? 100, maxQueue = opts.maxQueue ?? 500;
|
|
16
|
+
// The Fetch spec caps a keepalive body at 64 KiB and sendBeacon has a limit
|
|
17
|
+
// of its own, so a batch is bounded by bytes as well as by count.
|
|
18
|
+
const maxBatchBytes = opts.maxBatchBytes ?? 60 * 1024;
|
|
19
|
+
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
20
|
+
const beacon = opts.sendBeacon ?? (typeof navigator !== 'undefined' && navigator.sendBeacon ? navigator.sendBeacon.bind(navigator) : null);
|
|
21
|
+
const target = opts.target ?? globalThis;
|
|
22
|
+
const setI = opts.setInterval ?? globalThis.setInterval, clearI = opts.clearInterval ?? globalThis.clearInterval;
|
|
23
|
+
const now = opts.now ?? (() => Date.now());
|
|
24
|
+
|
|
25
|
+
let queue = [];
|
|
26
|
+
let droppedLocally = 0;
|
|
27
|
+
let failures = 0, backoffUntil = 0, inflight = false;
|
|
28
|
+
const stats = { sent: 0, lastError: null, lastStatus: null };
|
|
29
|
+
|
|
30
|
+
function trim() {
|
|
31
|
+
if (queue.length > maxQueue) { droppedLocally += queue.length - maxQueue; queue = queue.slice(queue.length - maxQueue); }
|
|
32
|
+
}
|
|
33
|
+
function drain() {
|
|
34
|
+
for (const s of sources) {
|
|
35
|
+
let r; try { r = s.drainRecords(); } catch { continue; }
|
|
36
|
+
if (r && r.length) queue.push(...r);
|
|
37
|
+
}
|
|
38
|
+
trim();
|
|
39
|
+
}
|
|
40
|
+
function body(records) {
|
|
41
|
+
const b = { records };
|
|
42
|
+
if (build) b.build = build;
|
|
43
|
+
if (droppedLocally) b.droppedLocally = droppedLocally;
|
|
44
|
+
return b;
|
|
45
|
+
}
|
|
46
|
+
const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
|
47
|
+
const byteLen = (s) => (encoder ? encoder.encode(s).length : s.length);
|
|
48
|
+
/** How many of the queue's leading records fit in one body under
|
|
49
|
+
* `maxBatchBytes`. A single record too large to ever fit is DROPPED and
|
|
50
|
+
* counted, never retried: re-prepending it would wedge the sink forever,
|
|
51
|
+
* and a silent wedge is the one failure this sink must not have. */
|
|
52
|
+
function fitCount() {
|
|
53
|
+
let envelope = byteLen(JSON.stringify(body([])));
|
|
54
|
+
let total = envelope, n = 0;
|
|
55
|
+
while (n < queue.length && n < maxBatch) {
|
|
56
|
+
const size = byteLen(JSON.stringify(queue[n])) + (n > 0 ? 1 : 0); // +1 for the comma
|
|
57
|
+
if (total + size > maxBatchBytes) {
|
|
58
|
+
if (n > 0) break;
|
|
59
|
+
queue.shift();
|
|
60
|
+
droppedLocally++;
|
|
61
|
+
stats.lastError = `record dropped: ${size} bytes over maxBatchBytes (${maxBatchBytes})`;
|
|
62
|
+
envelope = byteLen(JSON.stringify(body([]))); // droppedLocally just grew
|
|
63
|
+
total = envelope;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
total += size; n++;
|
|
67
|
+
}
|
|
68
|
+
return n;
|
|
69
|
+
}
|
|
70
|
+
async function flush() {
|
|
71
|
+
drain();
|
|
72
|
+
if (inflight || queue.length === 0 || now() < backoffUntil) return;
|
|
73
|
+
inflight = true;
|
|
74
|
+
// Remove the batch from the queue BEFORE sending, not after the await:
|
|
75
|
+
// otherwise a concurrent onHide()/enqueue() during the in-flight request
|
|
76
|
+
// sees records that are already (or about to be) accounted for elsewhere,
|
|
77
|
+
// causing duplicate delivery or silently corrupting the queue.
|
|
78
|
+
const errBefore = stats.lastError;
|
|
79
|
+
const n = fitCount();
|
|
80
|
+
// A success clears transport errors, but must not erase the report of a
|
|
81
|
+
// record this sink itself had to throw away in the same pass.
|
|
82
|
+
const droppedThisPass = stats.lastError !== errBefore;
|
|
83
|
+
if (n === 0) { inflight = false; return; } // everything queued was oversized
|
|
84
|
+
const batch = queue.splice(0, n);
|
|
85
|
+
const sentDropped = droppedLocally;
|
|
86
|
+
try {
|
|
87
|
+
// No `keepalive` here: it caps the body at 64 KiB in browsers, and the
|
|
88
|
+
// unload path already uses sendBeacon. This is the periodic flush.
|
|
89
|
+
const res = await fetchImpl(endpoint, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
|
|
92
|
+
body: JSON.stringify(body(batch)),
|
|
93
|
+
});
|
|
94
|
+
stats.lastStatus = res.status;
|
|
95
|
+
if (res.ok) {
|
|
96
|
+
droppedLocally = Math.max(0, droppedLocally - sentDropped);
|
|
97
|
+
failures = 0; backoffUntil = 0; stats.sent += batch.length; if (!droppedThisPass) stats.lastError = null;
|
|
98
|
+
} else if (res.status === 429 || res.status >= 500) {
|
|
99
|
+
// Retryable: put the batch back at the front (it's the oldest data)
|
|
100
|
+
// and re-apply the cap, counting any resulting drops.
|
|
101
|
+
queue = batch.concat(queue);
|
|
102
|
+
trim();
|
|
103
|
+
const ra = Number(res.headers?.get?.('retry-after'));
|
|
104
|
+
const wait = BACKOFF_MS[Math.min(failures, BACKOFF_MS.length - 1)];
|
|
105
|
+
backoffUntil = now() + (Number.isFinite(ra) && ra > 0 ? Math.max(ra * 1000, wait) : wait);
|
|
106
|
+
failures++;
|
|
107
|
+
stats.lastError = `HTTP ${res.status}`;
|
|
108
|
+
} else {
|
|
109
|
+
// 4xx other than 429: the batch is unacceptable and already out of
|
|
110
|
+
// the queue (spliced above); drop it rather than retry forever.
|
|
111
|
+
droppedLocally += batch.length;
|
|
112
|
+
stats.lastError = `HTTP ${res.status}`;
|
|
113
|
+
}
|
|
114
|
+
} catch (e) {
|
|
115
|
+
// Network failure: the batch never left, so put it back and retry later.
|
|
116
|
+
queue = batch.concat(queue);
|
|
117
|
+
trim();
|
|
118
|
+
stats.lastError = e?.message ?? String(e);
|
|
119
|
+
const wait = BACKOFF_MS[Math.min(failures, BACKOFF_MS.length - 1)];
|
|
120
|
+
backoffUntil = now() + wait; failures++;
|
|
121
|
+
} finally { inflight = false; }
|
|
122
|
+
}
|
|
123
|
+
function onHide() {
|
|
124
|
+
try {
|
|
125
|
+
drain();
|
|
126
|
+
if (queue.length === 0 || !beacon) return;
|
|
127
|
+
// Any batch currently in flight via flush() was already spliced out of
|
|
128
|
+
// `queue`, so what's here is guaranteed disjoint from it — no duplicate
|
|
129
|
+
// delivery risk.
|
|
130
|
+
const n = fitCount();
|
|
131
|
+
if (n === 0) return;
|
|
132
|
+
const batch = queue.slice(0, n);
|
|
133
|
+
const beaconedDropped = droppedLocally;
|
|
134
|
+
const ok = beacon(`${endpoint}?key=${encodeURIComponent(key)}`, new Blob([JSON.stringify(body(batch))], { type: 'application/json' }));
|
|
135
|
+
if (ok) { queue = queue.slice(batch.length); droppedLocally = Math.max(0, droppedLocally - beaconedDropped); stats.sent += batch.length; }
|
|
136
|
+
} catch { /* never throw into the host */ }
|
|
137
|
+
}
|
|
138
|
+
const timer = setI(() => { flush(); }, flushMs);
|
|
139
|
+
// Never keep a game server's (or any Node host's) event loop alive just
|
|
140
|
+
// to poll for records — this sink runs beside the host's own liveness,
|
|
141
|
+
// not instead of it. A browser's setInterval returns a number, so the
|
|
142
|
+
// optional chaining below is a no-op there.
|
|
143
|
+
timer?.unref?.();
|
|
144
|
+
target.addEventListener?.('pagehide', onHide);
|
|
145
|
+
const onVis = () => { if (typeof document !== 'undefined' && document.visibilityState === 'hidden') onHide(); };
|
|
146
|
+
target.addEventListener?.('visibilitychange', onVis);
|
|
147
|
+
return {
|
|
148
|
+
flush,
|
|
149
|
+
enqueue(records) {
|
|
150
|
+
// A host tee that hands over something other than an array is a wiring
|
|
151
|
+
// bug in the host, not a reason to throw into its drain loop.
|
|
152
|
+
if (!Array.isArray(records)) { stats.lastError = 'enqueue: expected an array of records'; return; }
|
|
153
|
+
if (records.length) queue.push(...records);
|
|
154
|
+
trim();
|
|
155
|
+
},
|
|
156
|
+
stats() { return { queued: queue.length, sent: stats.sent, droppedLocally, backoffUntil, lastError: stats.lastError, lastStatus: stats.lastStatus }; },
|
|
157
|
+
dispose() { clearI(timer); target.removeEventListener?.('pagehide', onHide); target.removeEventListener?.('visibilitychange', onVis); },
|
|
158
|
+
};
|
|
159
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// errors.js — the browser's errors as incidents (SPEC cloud §8.1)
|
|
3
|
+
// ============================================================
|
|
4
|
+
// An uncaught error is a bottleneck of a different kind: the frame it killed
|
|
5
|
+
// never rendered. It is recorded with the same identity model as a hitch —
|
|
6
|
+
// the footprint names the cause (class, normalized message, top frame),
|
|
7
|
+
// never the occurrence — so a thousand players hitting one bug is one row.
|
|
8
|
+
// The monitor NEVER preventDefault()s: the console and the host's own
|
|
9
|
+
// handlers see exactly what they saw before.
|
|
10
|
+
import { footprintOf } from './footprint.js';
|
|
11
|
+
|
|
12
|
+
export function createErrorMonitor(recorder, opts = {}) {
|
|
13
|
+
const target = opts.target ?? globalThis;
|
|
14
|
+
const dedupeMs = opts.dedupeMs ?? 10000;
|
|
15
|
+
const maxFrames = opts.maxFrames ?? 10;
|
|
16
|
+
const now = opts.now ?? (() => Date.now());
|
|
17
|
+
const lastByFp = new Map();
|
|
18
|
+
const stats = { seen: 0, emitted: 0, deduped: 0 };
|
|
19
|
+
|
|
20
|
+
// V8 frames start with "at "; SpiderMonkey/JavaScriptCore frames are
|
|
21
|
+
// "fn@url:line:col" (or "@url:line:col" when anonymous). Keeping only the
|
|
22
|
+
// V8 shape gave every Firefox and Safari player an empty stack — and a
|
|
23
|
+
// second catalogue row for the same bug.
|
|
24
|
+
const FRAME_RE = /^at |^[^\s]*@.+:\d+/;
|
|
25
|
+
function frames(stack) {
|
|
26
|
+
return String(stack ?? '').split('\n').map((l) => l.trim()).filter((l) => FRAME_RE.test(l)).slice(0, maxFrames);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A rejection reason that is not an Error, said as plainly as it can be
|
|
30
|
+
* said. JSON.stringify throws on a circular object (and on a throwing
|
|
31
|
+
* toJSON); an uncaught throw here would lose the incident entirely. */
|
|
32
|
+
function reasonMessage(r) {
|
|
33
|
+
if (typeof r === 'string') return r;
|
|
34
|
+
try { const j = JSON.stringify(r ?? null); if (j !== undefined) return j; } catch { /* circular / throwing toJSON */ }
|
|
35
|
+
try { return String(r); } catch { return '[unstringifiable]'; }
|
|
36
|
+
}
|
|
37
|
+
function toRecord(name, message, stack) {
|
|
38
|
+
return { type: 'error', at: new Date().toISOString(), source: 'client', name, message: String(message ?? ''), stack: frames(stack) };
|
|
39
|
+
}
|
|
40
|
+
function handle(rec) {
|
|
41
|
+
stats.seen++;
|
|
42
|
+
const fp = footprintOf(rec);
|
|
43
|
+
const t = now();
|
|
44
|
+
const last = fp ? lastByFp.get(fp.id) : undefined;
|
|
45
|
+
if (last !== undefined && t - last < dedupeMs) { stats.deduped++; return; }
|
|
46
|
+
if (fp) lastByFp.set(fp.id, t);
|
|
47
|
+
if (recorder.emit(rec)) stats.emitted++;
|
|
48
|
+
}
|
|
49
|
+
const onError = (ev) => {
|
|
50
|
+
try {
|
|
51
|
+
const e = ev?.error;
|
|
52
|
+
handle(e instanceof Error ? toRecord(e.name || 'Error', e.message, e.stack) : toRecord('Error', ev?.message ?? String(e ?? ''), ''));
|
|
53
|
+
} catch { /* never throw into the host */ }
|
|
54
|
+
};
|
|
55
|
+
const onRejection = (ev) => {
|
|
56
|
+
try {
|
|
57
|
+
const r = ev?.reason;
|
|
58
|
+
handle(r instanceof Error ? toRecord(r.name || 'Error', r.message, r.stack) : toRecord('UnhandledRejection', reasonMessage(r), ''));
|
|
59
|
+
} catch { /* never throw into the host */ }
|
|
60
|
+
};
|
|
61
|
+
target.addEventListener('error', onError);
|
|
62
|
+
target.addEventListener('unhandledrejection', onRejection);
|
|
63
|
+
return {
|
|
64
|
+
dispose() { target.removeEventListener('error', onError); target.removeEventListener('unhandledrejection', onRejection); },
|
|
65
|
+
stats() { return { ...stats }; },
|
|
66
|
+
};
|
|
67
|
+
}
|
package/src/footprint.js
CHANGED
|
@@ -71,6 +71,62 @@ export function contextOfKey(key) {
|
|
|
71
71
|
return out;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** Scrub an error message down to its shape: quoted strings, hex ids and
|
|
75
|
+
* numbers collapse to placeholders, whitespace and `|` (the key separator)
|
|
76
|
+
* are normalized, and the result is capped so a huge message never bloats a
|
|
77
|
+
* key. Two errors that differ only by which mesh id or how many ms are the
|
|
78
|
+
* SAME cause once normalized. */
|
|
79
|
+
export function normalizeErrorMessage(msg) {
|
|
80
|
+
return String(msg ?? '')
|
|
81
|
+
.replace(/(['"`])(?:\\.|(?!\1).)*\1/g, '"…"')
|
|
82
|
+
.replace(/0x[0-9a-f]{6,}/gi, '#')
|
|
83
|
+
.replace(/\b[0-9a-f]{6,}\b/gi, '#')
|
|
84
|
+
.replace(/\d+(\.\d+)?/g, '#')
|
|
85
|
+
.replace(/\s+/g, ' ')
|
|
86
|
+
.replace(/\|/g, '¦')
|
|
87
|
+
.trim()
|
|
88
|
+
.slice(0, 120);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `<path>#<function>` of the top stack frame, without origin, query, hash,
|
|
92
|
+
* line or column — the site an error is thrown from, apart from which build
|
|
93
|
+
* served it or which line the minifier put it on. */
|
|
94
|
+
export function topFrameSite(stack) {
|
|
95
|
+
const lines = Array.isArray(stack) ? stack : String(stack ?? '').split('\n');
|
|
96
|
+
for (const raw of lines) {
|
|
97
|
+
const line = String(raw).trim();
|
|
98
|
+
let fn, loc;
|
|
99
|
+
if (line.startsWith('at ')) {
|
|
100
|
+
// V8/Chromium/Node: "at fn (loc)" | "at loc" | "at async fn (loc)" | "at new Cls (loc)"
|
|
101
|
+
const m = /^at (?:(.+?) \()?(.+?)\)?$/.exec(line);
|
|
102
|
+
if (!m) continue;
|
|
103
|
+
fn = m[1] ? m[1].replace(/^(?:async |new )+/, '') : '';
|
|
104
|
+
loc = m[2];
|
|
105
|
+
} else {
|
|
106
|
+
// SpiderMonkey/JavaScriptCore: "fn@loc" | "@loc" (anonymous). Half the
|
|
107
|
+
// players are on Firefox or Safari; without this their stacks parse to
|
|
108
|
+
// nothing and one bug becomes two catalogue rows.
|
|
109
|
+
const m = /^([^@]*)@((?:[a-z][a-z0-9+.-]*:)?\/\/?.+?:\d+(?::\d+)?)$/.exec(line);
|
|
110
|
+
if (!m) continue;
|
|
111
|
+
fn = m[1];
|
|
112
|
+
loc = m[2];
|
|
113
|
+
}
|
|
114
|
+
loc = loc.replace(/:\d+:\d+$/, '').replace(/:\d+$/, '');
|
|
115
|
+
try { const u = new URL(loc); loc = u.pathname; } catch { /* not a URL: a path */ }
|
|
116
|
+
loc = loc.replace(/[?#].*$/, '');
|
|
117
|
+
return `${loc}#${fn || 'anonymous'}`;
|
|
118
|
+
}
|
|
119
|
+
return 'unknown';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The site a server incident's top self-time frame names — `unattributed`
|
|
123
|
+
* when attribution was turned off or the server sent no frames at all. */
|
|
124
|
+
function serverSite(rec) {
|
|
125
|
+
const f = Array.isArray(rec.frames) ? rec.frames[0] : undefined;
|
|
126
|
+
if (rec.attribution === 'off' || !f) return 'unattributed';
|
|
127
|
+
return `${f.file ?? '?'}#${f.fn ?? 'anonymous'}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
74
130
|
/** The verdict a record leads with, or 'unclassified'. */
|
|
75
131
|
function topGuess(rec) {
|
|
76
132
|
return rec.classification?.[0]?.guess ?? 'unclassified';
|
|
@@ -132,6 +188,12 @@ function baseKey(rec) {
|
|
|
132
188
|
case 'gpu-settle':
|
|
133
189
|
// Only a cap hit is an incident; a settled wait is verification evidence.
|
|
134
190
|
return rec.settled === false ? `gpu-settle|${rec.tag ?? '?'}` : null;
|
|
191
|
+
case 'error':
|
|
192
|
+
return `error|${rec.source ?? 'client'}|${rec.name ?? 'Error'}|${normalizeErrorMessage(rec.message)}|${topFrameSite(rec.stack)}`;
|
|
193
|
+
case 'server-hitch':
|
|
194
|
+
return `server-hitch|${phase}|${serverSite(rec)}`;
|
|
195
|
+
case 'server-stall':
|
|
196
|
+
return `server-stall|${phase}|${serverSite(rec)}`;
|
|
135
197
|
default:
|
|
136
198
|
return null;
|
|
137
199
|
}
|
|
@@ -152,11 +214,11 @@ export function footprintOf(rec) {
|
|
|
152
214
|
export function describeFootprint(key) {
|
|
153
215
|
const parts = String(key ?? '').split('|').filter((p) => !p.startsWith('ctx:'));
|
|
154
216
|
const ctx = contextOfKey(key);
|
|
155
|
-
const d = describeBase(parts);
|
|
217
|
+
const d = describeBase(parts, String(key ?? ''));
|
|
156
218
|
return { ...d, ctx };
|
|
157
219
|
}
|
|
158
220
|
|
|
159
|
-
function describeBase(parts) {
|
|
221
|
+
function describeBase(parts, key) {
|
|
160
222
|
const [type] = parts;
|
|
161
223
|
switch (type) {
|
|
162
224
|
case 'hitch': return { glyph: '⚡', label: `hitch · ${parts[2] ?? '?'}${parts[3] ? ` · ${parts[3].split(',').length} mint site(s)` : ''}`, phase: parts[1] ?? '?' };
|
|
@@ -165,6 +227,9 @@ function describeBase(parts) {
|
|
|
165
227
|
case 'warm': return { glyph: '🔥', label: `warm · ${parts[1] ?? '?'} (${parts[2] ?? '?'})`, phase: parts[3] ?? '?' };
|
|
166
228
|
case 'gpu-stall': return { glyph: '⏳', label: 'gpu-process stall', phase: parts[1] ?? '?' };
|
|
167
229
|
case 'gpu-settle': return { glyph: '⏳', label: `gpu-settle cap hit · ${parts[1] ?? '?'}`, phase: '' };
|
|
230
|
+
case 'error': return { glyph: '✖', label: `error · ${parts[2] ?? '?'} · ${(parts[3] ?? '').slice(0, 60)}`, phase: '' };
|
|
231
|
+
case 'server-hitch': return { glyph: '▣', label: `server tick over budget · ${parts[2] ?? '?'}`, phase: parts[1] ?? '?' };
|
|
232
|
+
case 'server-stall': return { glyph: '▦', label: `event-loop stall · ${parts[2] ?? '?'}`, phase: parts[1] ?? '?' };
|
|
168
233
|
default: return { glyph: '·', label: String(key ?? ''), phase: '' };
|
|
169
234
|
}
|
|
170
235
|
}
|
package/src/history.js
CHANGED
|
@@ -256,6 +256,9 @@ function worstOf(r) {
|
|
|
256
256
|
case 'warm': return typeof r.worstBatchMs === 'number' ? { value: r.worstBatchMs, unit: 'ms' } : undefined;
|
|
257
257
|
case 'gpu-stall': return typeof r.queueDoneMs === 'number' ? { value: r.queueDoneMs, unit: 'ms' } : undefined;
|
|
258
258
|
case 'gpu-settle': return typeof r.ms === 'number' ? { value: r.ms, unit: 'ms' } : undefined;
|
|
259
|
+
case 'error': return undefined;
|
|
260
|
+
case 'server-hitch': return typeof r.tickMs === 'number' ? { value: r.tickMs, unit: 'ms' } : undefined;
|
|
261
|
+
case 'server-stall': return typeof r.p99Ms === 'number' ? { value: r.p99Ms, unit: 'ms' } : undefined;
|
|
259
262
|
default: return undefined;
|
|
260
263
|
}
|
|
261
264
|
}
|
package/src/index.js
CHANGED
|
@@ -3,6 +3,8 @@ export { createRecorder } from './recorder.js';
|
|
|
3
3
|
export { buildCensus } from './census.js';
|
|
4
4
|
export { classifyHitch } from './classify.js';
|
|
5
5
|
export { createMotionMonitor } from './motion.js';
|
|
6
|
+
export { createErrorMonitor } from './errors.js';
|
|
7
|
+
export { createCloudSink } from './cloud-sink.js';
|
|
6
8
|
export { footprintOf, footprintKey, describeFootprint, canonicalContext, contextOfKey, FOOTPRINT_VERSION } from './footprint.js';
|
|
7
9
|
export { buildHistory, summarizeWindow, buildFix, latestBuilds, buildIssues, agoText } from './history.js';
|
|
8
10
|
export { createPanel } from './panel.js';
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// sloptimize/node — the game server's recorder (SPEC cloud §8.3)
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Three signals, one ledger: a tick the host timed that overran its budget
|
|
5
|
+
// (server-hitch), an event-loop stall the runtime saw on its own
|
|
6
|
+
// (server-stall), and an uncaught error (error, source: server). Each is
|
|
7
|
+
// attributed by the V8 sampler's top self-time frames, and shipped with the
|
|
8
|
+
// same cloud sink the browser uses. Nothing here changes how the process
|
|
9
|
+
// dies: only uncaughtExceptionMonitor is registered — never uncaughtException
|
|
10
|
+
// or unhandledRejection, which would turn this recorder into part of the
|
|
11
|
+
// crash path itself.
|
|
12
|
+
import { createCloudSink } from '../cloud-sink.js';
|
|
13
|
+
import { canonicalContext } from '../footprint.js';
|
|
14
|
+
// Static import: monitorEventLoopDelay() is synchronous and cheap, and
|
|
15
|
+
// createServerRuntime() must itself stay synchronous (callers do not await
|
|
16
|
+
// it), so this cannot be a dynamic `await import('node:perf_hooks')`.
|
|
17
|
+
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
|
18
|
+
|
|
19
|
+
export function createServerRuntime(opts = {}) {
|
|
20
|
+
if (!opts.key) throw new Error('createServerRuntime: key is required');
|
|
21
|
+
if (!opts.endpoint) throw new Error('createServerRuntime: endpoint is required');
|
|
22
|
+
const tickBudgetMs = opts.tickBudgetMs ?? 16, stallMs = opts.stallMs ?? 50;
|
|
23
|
+
const phaseFn = opts.phase ?? (() => undefined), ctxFn = opts.context ?? (() => undefined);
|
|
24
|
+
const now = opts.now ?? (() => Date.now());
|
|
25
|
+
const proc = opts.process ?? process;
|
|
26
|
+
const setI = opts.setInterval ?? setInterval, clearI = opts.clearInterval ?? clearInterval;
|
|
27
|
+
const profileOn = opts.profile !== false;
|
|
28
|
+
let closed = false;
|
|
29
|
+
|
|
30
|
+
const pending = [];
|
|
31
|
+
const source = { drainRecords() { return pending.splice(0, pending.length); } };
|
|
32
|
+
const sink = createCloudSink({
|
|
33
|
+
key: opts.key, endpoint: opts.endpoint, build: opts.build, sources: [source], flushMs: opts.flushMs ?? 5000,
|
|
34
|
+
fetch: opts.fetch, sendBeacon: null, target: { addEventListener() {}, removeEventListener() {} }, setInterval: setI, clearInterval: clearI, now,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const stats = { hitches: 0, stalls: 0, errors: 0, droppedByRate: 0, hostErrors: 0 };
|
|
38
|
+
let lastHitchAt = -Infinity, lastStallAt = -Infinity;
|
|
39
|
+
|
|
40
|
+
// The profiler: injectable for tests (never touches node:inspector), or
|
|
41
|
+
// lazily imported for real use so the fake-profiler test path never loads
|
|
42
|
+
// the inspector module. `closed` is re-checked once the import (and the
|
|
43
|
+
// inspector session it builds) resolves, so a close() that races the lazy
|
|
44
|
+
// init stops the profiler instead of starting a session nobody will ever
|
|
45
|
+
// stop.
|
|
46
|
+
let profiler = opts.profiler ?? null;
|
|
47
|
+
let profilerReady = profileOn && profiler ? Promise.resolve(profiler.start()) : null;
|
|
48
|
+
if (profileOn && !profiler) {
|
|
49
|
+
profilerReady = import('./profiler.js')
|
|
50
|
+
.then(async (m) => {
|
|
51
|
+
if (closed) return;
|
|
52
|
+
const p = await m.createProfiler();
|
|
53
|
+
if (closed) { await p.stop?.(); return; }
|
|
54
|
+
profiler = p;
|
|
55
|
+
await profiler.start();
|
|
56
|
+
})
|
|
57
|
+
.catch((e) => { stats.profilerError = e?.message; profiler = null; });
|
|
58
|
+
}
|
|
59
|
+
async function frames() {
|
|
60
|
+
if (!profileOn) return { frames: [], attribution: 'off' };
|
|
61
|
+
try {
|
|
62
|
+
// Wait for the (possibly still-lazily-importing) profiler before
|
|
63
|
+
// deciding attribution is unavailable — otherwise every hitch/stall
|
|
64
|
+
// during startup is permanently recorded as attribution: 'off' even
|
|
65
|
+
// though the real profiler comes up moments later.
|
|
66
|
+
await profilerReady;
|
|
67
|
+
if (!profiler) return { frames: [], attribution: 'off' };
|
|
68
|
+
return { frames: await profiler.take(), attribution: 'profiler' };
|
|
69
|
+
} catch { return { frames: [], attribution: 'off' }; }
|
|
70
|
+
}
|
|
71
|
+
// Host-supplied phase()/context() callbacks run on every record; a
|
|
72
|
+
// throwing one must never break the record (or crash a bare setInterval
|
|
73
|
+
// callback) — it just leaves that field unset, counted in hostErrors.
|
|
74
|
+
function stamp(rec) {
|
|
75
|
+
try { const p = phaseFn(); if (p) rec.phase = p; } catch { stats.hostErrors++; }
|
|
76
|
+
try { const c = ctxFn(); if (c) rec.ctx = typeof c === 'string' ? c : canonicalContext(c); } catch { stats.hostErrors++; }
|
|
77
|
+
rec.at = new Date().toISOString();
|
|
78
|
+
return rec;
|
|
79
|
+
}
|
|
80
|
+
function pushAsync(rec, withFrames) {
|
|
81
|
+
// After close() the sink is disposed and nothing will ever drain `pending`
|
|
82
|
+
// again: a frame promise that resolves later must not grow it forever.
|
|
83
|
+
if (closed) return;
|
|
84
|
+
if (!withFrames) { pending.push(rec); return; }
|
|
85
|
+
frames().then((f) => {
|
|
86
|
+
if (closed) return;
|
|
87
|
+
rec.frames = f.frames; rec.attribution = f.attribution; pending.push(rec);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function endTick(startMs) {
|
|
92
|
+
try {
|
|
93
|
+
const tickMs = now() - startMs;
|
|
94
|
+
if (tickMs <= tickBudgetMs) return;
|
|
95
|
+
const t = now();
|
|
96
|
+
if (t - lastHitchAt < 1000) { stats.droppedByRate++; return; }
|
|
97
|
+
lastHitchAt = t; stats.hitches++;
|
|
98
|
+
const rec = stamp({ type: 'server-hitch', tickMs: +tickMs.toFixed(2), budgetMs: tickBudgetMs, frames: [], attribution: 'off' });
|
|
99
|
+
pushAsync(rec, profileOn);
|
|
100
|
+
} catch { stats.hostErrors++; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Event-loop delay: sampled every second, an incident when p99 crosses
|
|
104
|
+
// stallMs, rate-limited to one record per 5s.
|
|
105
|
+
const monitor = opts.monitor ?? monitorEventLoopDelay({ resolution: 20 });
|
|
106
|
+
monitor?.enable?.();
|
|
107
|
+
const sampler = setI(() => {
|
|
108
|
+
try {
|
|
109
|
+
if (!monitor) return;
|
|
110
|
+
const p99 = monitor.percentiles.get(99) / 1e6, p50 = monitor.percentiles.get(50) / 1e6, max = monitor.max / 1e6;
|
|
111
|
+
monitor.reset();
|
|
112
|
+
if (!(p99 > stallMs)) return;
|
|
113
|
+
const t = now();
|
|
114
|
+
if (t - lastStallAt < 5000) { stats.droppedByRate++; return; }
|
|
115
|
+
lastStallAt = t; stats.stalls++;
|
|
116
|
+
const rec = stamp({ type: 'server-stall', p50Ms: +p50.toFixed(1), p99Ms: +p99.toFixed(1), maxMs: +max.toFixed(1), frames: [], attribution: 'off' });
|
|
117
|
+
pushAsync(rec, profileOn);
|
|
118
|
+
} catch { stats.hostErrors++; }
|
|
119
|
+
}, 1000);
|
|
120
|
+
sampler?.unref?.();
|
|
121
|
+
|
|
122
|
+
const onUncaught = (err) => {
|
|
123
|
+
try {
|
|
124
|
+
stats.errors++;
|
|
125
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
126
|
+
const stack = String(e.stack ?? '').split('\n').map((l) => l.trim()).filter((l) => l.startsWith('at ')).slice(0, 10);
|
|
127
|
+
pending.push(stamp({ type: 'error', source: 'server', name: e.name || 'Error', message: e.message, stack }));
|
|
128
|
+
} catch { /* never throw from a monitor */ }
|
|
129
|
+
};
|
|
130
|
+
proc.on('uncaughtExceptionMonitor', onUncaught);
|
|
131
|
+
// The watchdog timer must be unref'd too: Promise.race never cancels its
|
|
132
|
+
// losing arm, so a plain setTimeout here would itself become a handle
|
|
133
|
+
// that keeps a real host's event loop alive for up to 2s on every exit
|
|
134
|
+
// attempt (and, since that can make the loop look non-empty again,
|
|
135
|
+
// potentially re-trigger beforeExit indefinitely).
|
|
136
|
+
const onBeforeExit = () => {
|
|
137
|
+
Promise.race([sink.flush(), new Promise((r) => { const t = setTimeout(r, 2000); t?.unref?.(); })]).catch(() => {});
|
|
138
|
+
};
|
|
139
|
+
proc.on('beforeExit', onBeforeExit);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
tick(fn) { const s = now(); try { return fn(); } finally { endTick(s); } },
|
|
143
|
+
beginTick() { return now(); },
|
|
144
|
+
endTick,
|
|
145
|
+
mark(label, meta = {}) {
|
|
146
|
+
try { pending.push(stamp({ type: 'usermark', label, ...meta })); } catch { stats.hostErrors++; }
|
|
147
|
+
},
|
|
148
|
+
// Awaits one macrotask before draining the sink so that pushAsync's
|
|
149
|
+
// frame promise (resolved on a microtask, even for a synchronous fake
|
|
150
|
+
// profiler) has already landed its record in `pending`.
|
|
151
|
+
async flush() { await new Promise((r) => setTimeout(r, 0)); await sink.flush(); },
|
|
152
|
+
stats() { return { ...stats, sink: sink.stats() }; },
|
|
153
|
+
async close() {
|
|
154
|
+
closed = true;
|
|
155
|
+
clearI(sampler);
|
|
156
|
+
monitor?.disable?.();
|
|
157
|
+
proc.off?.('uncaughtExceptionMonitor', onUncaught);
|
|
158
|
+
proc.off?.('beforeExit', onBeforeExit);
|
|
159
|
+
await profiler?.stop?.();
|
|
160
|
+
await sink.flush();
|
|
161
|
+
sink.dispose();
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// profiler.js — the V8 sampling profiler over node:inspector (SPEC cloud §8.3)
|
|
3
|
+
// ============================================================
|
|
4
|
+
// The V8 sampling profiler over the inspector protocol: started once, read
|
|
5
|
+
// on demand. take() returns the top self-time frames since the last take and
|
|
6
|
+
// restarts the sampler — the same "rolling window" attach.mjs uses in the
|
|
7
|
+
// browser, here for the game server's own event loop.
|
|
8
|
+
|
|
9
|
+
/** Pure fold of a Profiler.stop() result's `.profile` into the top 5
|
|
10
|
+
* self-time frames as `{ file, fn, selfMs }`, keyed by (url, functionName).
|
|
11
|
+
* Runtime/VM bookkeeping frames (root/program/gc/idle with no url) are
|
|
12
|
+
* dropped, and any frame whose url contains `excludeUrl` (the profiler's
|
|
13
|
+
* own module) is dropped so the recorder never attributes its own cost. */
|
|
14
|
+
export function foldProfile(profile, excludeUrl) {
|
|
15
|
+
const byId = new Map((profile?.nodes ?? []).map((n) => [n.id, n]));
|
|
16
|
+
const selfUs = new Map();
|
|
17
|
+
const samples = profile?.samples ?? [], deltas = profile?.timeDeltas ?? [];
|
|
18
|
+
for (let i = 0; i < samples.length; i++) {
|
|
19
|
+
const n = byId.get(samples[i]); if (!n) continue;
|
|
20
|
+
const { functionName, url } = n.callFrame;
|
|
21
|
+
if (!url && /^\((root|program|garbage collector|idle)\)$/.test(functionName)) continue;
|
|
22
|
+
if (excludeUrl && url.includes(excludeUrl)) continue;
|
|
23
|
+
// NUL, not a space: getters fold as "get health" and real paths contain
|
|
24
|
+
// spaces, both of which a space-separated key would split in the wrong place.
|
|
25
|
+
const key = `${url}\u0000${functionName}`;
|
|
26
|
+
selfUs.set(key, (selfUs.get(key) ?? 0) + (deltas[i] ?? 0));
|
|
27
|
+
}
|
|
28
|
+
return [...selfUs.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k, us]) => {
|
|
29
|
+
const [url, fn] = k.split('\u0000');
|
|
30
|
+
return { file: url.replace(/^file:\/\//, ''), fn: fn || 'anonymous', selfMs: Math.round(us / 1000) };
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Wraps an inspector Session (real or injected) in start/take/stop. `take()`
|
|
35
|
+
* stops the sampler, folds the profile, and immediately restarts it so the
|
|
36
|
+
* window is continuous. Never throws into the host — callers treat a
|
|
37
|
+
* rejected/absent profiler the same as attribution being off. */
|
|
38
|
+
export async function createProfiler(opts = {}) {
|
|
39
|
+
const session = opts.session ?? new (await import('node:inspector')).Session();
|
|
40
|
+
const post = (m, p) => new Promise((res, rej) => session.post(m, p ?? {}, (e, r) => (e ? rej(e) : res(r))));
|
|
41
|
+
let running = false;
|
|
42
|
+
return {
|
|
43
|
+
async start() {
|
|
44
|
+
if (running) return;
|
|
45
|
+
session.connect?.();
|
|
46
|
+
await post('Profiler.enable');
|
|
47
|
+
await post('Profiler.setSamplingInterval', { interval: opts.intervalUs ?? 1000 });
|
|
48
|
+
await post('Profiler.start');
|
|
49
|
+
running = true;
|
|
50
|
+
},
|
|
51
|
+
async take() {
|
|
52
|
+
if (!running) return [];
|
|
53
|
+
const { profile } = await post('Profiler.stop');
|
|
54
|
+
await post('Profiler.start');
|
|
55
|
+
return foldProfile(profile, '/sloptimize/src/node/');
|
|
56
|
+
},
|
|
57
|
+
async stop() {
|
|
58
|
+
if (!running) return;
|
|
59
|
+
running = false;
|
|
60
|
+
try { await post('Profiler.stop'); await post('Profiler.disable'); } catch { /* already gone */ }
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
package/src/recorder.js
CHANGED
|
@@ -43,6 +43,8 @@ export function createRecorder(opts = {}) {
|
|
|
43
43
|
let sessionRecords = 0;
|
|
44
44
|
let droppedSinceLast = 0;
|
|
45
45
|
let lastRecordAt = -Infinity;
|
|
46
|
+
let lastPhase; // most recent s.phase seen by frame(), for emit()'s stamping
|
|
47
|
+
let lastCtx; // most recent s.ctx seen by frame(), for emit()'s stamping
|
|
46
48
|
|
|
47
49
|
function sortedNonPaused(field, sinceIdx = 0) {
|
|
48
50
|
const vals = [];
|
|
@@ -73,6 +75,8 @@ export function createRecorder(opts = {}) {
|
|
|
73
75
|
return {
|
|
74
76
|
/** One frame's numbers. Zero-allocation on the steady path. */
|
|
75
77
|
frame(s) {
|
|
78
|
+
if (s.phase) lastPhase = s.phase;
|
|
79
|
+
if (s.ctx) lastCtx = s.ctx;
|
|
76
80
|
const idx = head;
|
|
77
81
|
for (const f of FIELDS) lanes[f][idx] = s[f] ?? 0;
|
|
78
82
|
pausedLane[idx] = s.paused ? 1 : 0;
|
|
@@ -229,6 +233,19 @@ export function createRecorder(opts = {}) {
|
|
|
229
233
|
return mark;
|
|
230
234
|
},
|
|
231
235
|
|
|
236
|
+
/** Append an externally built incident (errors, host-detected events). Same session cap as hitches. */
|
|
237
|
+
emit(rec) {
|
|
238
|
+
if (!rec || typeof rec !== 'object') return false;
|
|
239
|
+
if (sessionRecords >= MAX_RECORDS_PER_SESSION) { droppedSinceLast++; return false; }
|
|
240
|
+
sessionRecords++;
|
|
241
|
+
if (!rec.at) rec.at = new Date().toISOString();
|
|
242
|
+
if (rec.phase === undefined && lastPhase) rec.phase = lastPhase;
|
|
243
|
+
if (rec.ctx === undefined && lastCtx) rec.ctx = lastCtx;
|
|
244
|
+
if (droppedSinceLast > 0) { rec.droppedSinceLast = droppedSinceLast; droppedSinceLast = 0; }
|
|
245
|
+
records.push(rec);
|
|
246
|
+
return true;
|
|
247
|
+
},
|
|
248
|
+
|
|
232
249
|
/** Hand back accumulated records and clear — the host owns transport. */
|
|
233
250
|
drainRecords() { const r = records; records = []; return r; },
|
|
234
251
|
};
|