@ogygia/devtools 0.11.4 → 0.13.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/README.md +1 -1
- package/dist/app.d.ts +3 -0
- package/dist/app.js +9 -0
- package/dist/devtools/BoundaryOverlay.svelte +10 -6
- package/dist/devtools/Devtools.svelte +24 -18
- package/dist/devtools/IslandDetail.svelte +7 -5
- package/dist/devtools/PageTab.svelte +4 -2
- package/dist/devtools/ProfilerTab.svelte +8 -2
- package/dist/devtools/SessionTab.svelte +21 -8
- package/dist/devtools/highlight.js +14 -7
- package/dist/devtools/ledger-dom.js +6 -9
- package/dist/devtools/names.js +18 -17
- package/dist/devtools/page.d.ts +2 -0
- package/dist/devtools/page.js +37 -17
- package/dist/devtools/profile-store.d.ts +4 -9
- package/dist/devtools/profile-store.js +26 -21
- package/dist/devtools/session.d.ts +2 -0
- package/dist/devtools/session.js +48 -42
- package/dist/devtools/testing-engine.js +10 -1
- package/dist/devtools/ui-app.d.ts +3 -1
- package/dist/devtools/ui-app.js +9 -3
- package/dist/devtools/ui.js +83 -40
- package/dist/messages/build.d.ts +21 -0
- package/dist/messages/build.js +56 -0
- package/dist/messages/index.d.ts +3 -0
- package/dist/messages/index.js +3 -0
- package/dist/messages/runtime.d.ts +63 -0
- package/dist/messages/runtime.js +122 -0
- package/dist/profiler/async-io.d.ts +12 -7
- package/dist/profiler/async-io.js +94 -82
- package/dist/profiler/beacon-standalone.js +15 -6
- package/dist/profiler/busy.d.ts +13 -0
- package/dist/profiler/busy.js +45 -0
- package/dist/profiler/client.js +5 -24
- package/dist/profiler/embedded-maps.js +9 -5
- package/dist/profiler/gc.d.ts +5 -0
- package/dist/profiler/gc.js +75 -11
- package/dist/profiler/index.d.ts +23 -2
- package/dist/profiler/index.js +648 -373
- package/dist/profiler/net.d.ts +14 -6
- package/dist/profiler/net.js +61 -45
- package/dist/profiler/patterns.js +29 -1
- package/dist/profiler/source-scan.js +24 -12
- package/dist/profiler/standalone.js +12 -4
- package/dist/profiler/storage/index.d.ts +14 -3
- package/dist/profiler/storage/index.js +41 -41
- package/dist/profiler/storage/postgres.d.ts +18 -5
- package/dist/profiler/storage/postgres.js +124 -85
- package/dist/profiler/storage/redis.d.ts +18 -5
- package/dist/profiler/storage/redis.js +160 -131
- package/dist/profiler/storage/sqlite.d.ts +15 -2
- package/dist/profiler/storage/sqlite.js +54 -48
- package/dist/profiler/ui/ComponentsTable.svelte +2 -2
- package/dist/profiler/ui/Dashboard.svelte +2 -3
- package/dist/profiler/ui/DownloadHtml.svelte +1 -0
- package/dist/profiler/ui/ExportButton.svelte +1 -0
- package/dist/profiler/ui/Flame.svelte +10 -4
- package/dist/profiler/ui/Flame.svelte.d.ts +0 -5
- package/dist/profiler/ui/FrameDetails.svelte +13 -2
- package/dist/profiler/ui/FrameDetails.svelte.d.ts +0 -6
- package/dist/profiler/ui/FunctionsTable.svelte +2 -2
- package/dist/profiler/ui/IslandsTable.svelte +2 -2
- package/dist/profiler/ui/OnThisPage.svelte +12 -4
- package/dist/profiler/ui/RunView.svelte +31 -27
- package/dist/profiler/ui/ShareLink.svelte +6 -1
- package/dist/profiler/ui/Sidebar.svelte +14 -9
- package/dist/profiler/ui/Treemap.svelte +11 -4
- package/dist/profiler/ui/Treemap.svelte.d.ts +0 -6
- package/dist/profiler/ui/WaitingTable.svelte +2 -2
- package/dist/profiler/ui/fake-progress.svelte.d.ts +7 -12
- package/dist/profiler/ui/fake-progress.svelte.js +57 -36
- package/dist/profiler/ui/press.js +4 -2
- package/dist/profiler/ui/row-anchor.svelte.d.ts +0 -6
- package/dist/profiler/ui/row-anchor.svelte.js +13 -8
- package/dist/profiler/ui/sort.svelte.d.ts +9 -6
- package/dist/profiler/ui/sort.svelte.js +34 -28
- package/dist/profiler/visit.d.ts +0 -1
- package/dist/profiler/visit.js +1 -2
- package/dist/server.js +27 -10
- package/dist/vite.js +3 -2
- package/package.json +18 -9
package/dist/profiler/index.js
CHANGED
|
@@ -6,9 +6,9 @@ import { is_ogp, ogp_decode, ogp_encode, recover_ogp_bytes } from "./crypto.js";
|
|
|
6
6
|
import { AFTER_LOGIN_PARAM, SESSION_COOKIE, describe_cookie_diagnosis, raw_cookie_values, session_cookie_values } from "./session-cookie.js";
|
|
7
7
|
import { label_call, load_lane_of, phase_of_frame } from "./timeline.js";
|
|
8
8
|
import { merge_visits, parse_visit } from "./visit.js";
|
|
9
|
-
import { own_requests } from "./own-requests.js";
|
|
10
9
|
import { MAX_VISITS_PER_KEY } from "./storage/visits.js";
|
|
11
10
|
import { hole_slots } from "./hole-slots.js";
|
|
11
|
+
import { own_requests } from "./own-requests.js";
|
|
12
12
|
import { client_windows, interaction_windows } from "./client-windows.js";
|
|
13
13
|
import { byte_strip } from "./byte-strip.js";
|
|
14
14
|
import { stream_tail } from "./stream-tail.js";
|
|
@@ -22,6 +22,7 @@ import { build_lineage, data_prop_names, data_reads, whole_read_line } from "./l
|
|
|
22
22
|
import { build_ledger, pkg_of } from "./ledger.js";
|
|
23
23
|
import { at_line, build_drill, call_group, tag_fills } from "./drill.js";
|
|
24
24
|
import { forecast_of } from "./forecast.js";
|
|
25
|
+
import { OgygiaError, warn } from "../messages/runtime.js";
|
|
25
26
|
import { site_fixes } from "./site-fixes.js";
|
|
26
27
|
import { may_be_chunk, module_category, module_lookup } from "./module-map.js";
|
|
27
28
|
import { embedded_chunk, embedded_files, embedded_map_of, embedded_source, load_embedded_maps } from "./embedded-maps.js";
|
|
@@ -35,12 +36,14 @@ import { gzip_large } from "./compress.js";
|
|
|
35
36
|
import { io_kind, mark_left_each_run } from "./async-io.js";
|
|
36
37
|
import { set_span_recorder } from "./span.js";
|
|
37
38
|
import { register_profiler_file } from "./frames.js";
|
|
39
|
+
import { devtools_app } from "../app.js";
|
|
38
40
|
import { app_asset_rel, client_dir_candidates, client_file_finder } from "./client-files.js";
|
|
39
41
|
import { build_profiler_router } from "./profiler-router.js";
|
|
40
42
|
import { detect_dev } from "./env.js";
|
|
41
43
|
import { refresh_target } from "./refresh-stub.js";
|
|
42
44
|
import { assets_diff, engine_of, hydration_hazards, vital_parts, weigh_assets } from "@ogygia/core/insights";
|
|
43
|
-
import {
|
|
45
|
+
import { STALE, flights, lock, machine, outbox, policy, scope } from "@ogygia/machine";
|
|
46
|
+
import { current_request_scope, hold_request_stats_detail, hole_stats_of, mark_personal, request_scope, request_stats_of, set_batch_hole_listener } from "@ogygia/core/internal/server";
|
|
44
47
|
import { fnv1a32 } from "@ogygia/core/plugin/utils";
|
|
45
48
|
import { chunkBarrels, chunkContents, chunkDuplicates, chunkHeavy, islandHazards, islandPageKeys, islandPageWhy } from "virtual:ogygia/island-deps";
|
|
46
49
|
import { error, isHttpError, isRedirect } from "@ogygia/router";
|
|
@@ -84,6 +87,22 @@ async function read_timed(res, t0) {
|
|
|
84
87
|
chunks
|
|
85
88
|
};
|
|
86
89
|
}
|
|
90
|
+
/** How a sink post is tried again: a sink restarting is ridden out, and a sink that is down costs
|
|
91
|
+
* each post 30 s at most, so the posts after it are not held long. */
|
|
92
|
+
const SINK_POLICY = /* @__PURE__ */ policy({
|
|
93
|
+
attempts: 4,
|
|
94
|
+
backoff: [
|
|
95
|
+
1e3,
|
|
96
|
+
5e3,
|
|
97
|
+
1e4
|
|
98
|
+
],
|
|
99
|
+
jitter: .2,
|
|
100
|
+
deadline: 3e4
|
|
101
|
+
});
|
|
102
|
+
/** The longest one try at a post waits for the sink's answer. */
|
|
103
|
+
const SINK_TRY_MS = 5e3;
|
|
104
|
+
const error_text = (error) => error instanceof Error ? error.message : String(error);
|
|
105
|
+
const noop = () => {};
|
|
87
106
|
register_profiler_file();
|
|
88
107
|
const MAX_VITALS_PATHS = 200;
|
|
89
108
|
const MAX_VITALS_SAMPLES = 30;
|
|
@@ -226,36 +245,52 @@ function is_hex_id(s) {
|
|
|
226
245
|
}
|
|
227
246
|
return true;
|
|
228
247
|
}
|
|
248
|
+
/** A status that says the file is not there for good: a client error, but not a timeout or a
|
|
249
|
+
* rate limit (408, 429), which say "not now". */
|
|
250
|
+
const gone_for_good = (status) => status >= 400 && status < 500 && status !== 408 && status !== 429;
|
|
251
|
+
/** The process's URL weights: an answer (bytes, or a file that is not there) is kept; a failure
|
|
252
|
+
* (a 5xx, a timeout, the network) is not, so the next report asks again. Reports weighing the
|
|
253
|
+
* same file at once share one fetch. */
|
|
254
|
+
const url_weights = () => flights.keyed()({
|
|
255
|
+
name: "profiler url weights",
|
|
256
|
+
key: ["url"],
|
|
257
|
+
ignore: { fetch_url: "every report fetches the same hashed file through the app" },
|
|
258
|
+
run: async ({ url, fetch_url }, signal) => {
|
|
259
|
+
const res = await fetch_url(url, signal);
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
res.body?.cancel().catch(noop);
|
|
262
|
+
if (gone_for_good(res.status)) return null;
|
|
263
|
+
throw new Error(`status ${res.status}`);
|
|
264
|
+
}
|
|
265
|
+
const len = Number(res.headers.get("content-length"));
|
|
266
|
+
if (!res.headers.get("content-encoding") && Number.isFinite(len) && len > 0) {
|
|
267
|
+
res.body?.cancel().catch(noop);
|
|
268
|
+
return len;
|
|
269
|
+
}
|
|
270
|
+
return (await res.arrayBuffer()).byteLength;
|
|
271
|
+
}
|
|
272
|
+
});
|
|
229
273
|
/** GET each URL through the app's own fetch and count its bytes — the JS weight of an island's
|
|
230
|
-
* closure.
|
|
231
|
-
*
|
|
232
|
-
async function weigh_urls(urls, fetch_url,
|
|
233
|
-
const todo = [...new Set(urls)].filter((u) => u
|
|
234
|
-
const
|
|
274
|
+
* closure. Kept per process (`url_weights`), a handful at a time, capped in time so a slow static
|
|
275
|
+
* host can never hold a report hostage: a URL not weighed within the budget is left out. */
|
|
276
|
+
async function weigh_urls(urls, fetch_url, weights, budget_ms = 4e3) {
|
|
277
|
+
const todo = [...new Set(urls)].filter((u) => u);
|
|
278
|
+
const budget = AbortSignal.timeout(budget_ms);
|
|
279
|
+
const out = {};
|
|
235
280
|
let i = 0;
|
|
236
281
|
const worker = async () => {
|
|
237
|
-
while (i < todo.length &&
|
|
238
|
-
const
|
|
282
|
+
while (i < todo.length && !budget.aborted) {
|
|
283
|
+
const url = todo[i++];
|
|
239
284
|
try {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const bytes = !res.headers.get("content-encoding") && Number.isFinite(len) && len > 0 ? len : (await res.arrayBuffer()).byteLength;
|
|
247
|
-
cache.set(u, bytes);
|
|
248
|
-
} catch {
|
|
249
|
-
cache.set(u, null);
|
|
250
|
-
}
|
|
285
|
+
const bytes = await weights.get({
|
|
286
|
+
url,
|
|
287
|
+
fetch_url
|
|
288
|
+
}, { signal: budget });
|
|
289
|
+
if (typeof bytes === "number") out[url] = bytes;
|
|
290
|
+
} catch {}
|
|
251
291
|
}
|
|
252
292
|
};
|
|
253
293
|
await Promise.all(Array.from({ length: Math.min(6, todo.length) }, worker));
|
|
254
|
-
const out = {};
|
|
255
|
-
for (const u of new Set(urls)) {
|
|
256
|
-
const b = cache.get(u);
|
|
257
|
-
if (typeof b === "number") out[u] = b;
|
|
258
|
-
}
|
|
259
294
|
return out;
|
|
260
295
|
}
|
|
261
296
|
const percentile = (sorted, p) => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] ?? 0;
|
|
@@ -282,6 +317,62 @@ function summarize_measures(entries) {
|
|
|
282
317
|
const MAX_SPANS_PER_REQUEST = 2e3;
|
|
283
318
|
const MAX_WINDOW_SPANS = 2e4;
|
|
284
319
|
const MAX_TAGS = 16;
|
|
320
|
+
/** The span recorder one recording window installs (`span()` / `tag()` from ogygia/profiler): every
|
|
321
|
+
* span the window sees, nested through the request's async context, or through the window's own
|
|
322
|
+
* store where no request is attributed. */
|
|
323
|
+
var WindowSpans = class {
|
|
324
|
+
/** every span recorded during the window, any request */
|
|
325
|
+
spans = [];
|
|
326
|
+
#seq = 0;
|
|
327
|
+
#als;
|
|
328
|
+
#span_als;
|
|
329
|
+
#netmod;
|
|
330
|
+
constructor(als, span_als, netmod) {
|
|
331
|
+
this.#als = als;
|
|
332
|
+
this.#span_als = span_als;
|
|
333
|
+
this.#netmod = netmod;
|
|
334
|
+
}
|
|
335
|
+
begin(name, attrs) {
|
|
336
|
+
const ctx = this.#als?.getStore();
|
|
337
|
+
const parent = ctx?.span ?? this.#span_als.getStore();
|
|
338
|
+
const rec = {
|
|
339
|
+
id: ++this.#seq,
|
|
340
|
+
name: String(name).slice(0, 120),
|
|
341
|
+
start: performance.now(),
|
|
342
|
+
ms: -1,
|
|
343
|
+
...attrs ? { attrs: { ...attrs } } : {},
|
|
344
|
+
...parent !== void 0 ? { parent } : {},
|
|
345
|
+
caller_site: this.#netmod?.nearest_app_site(),
|
|
346
|
+
route: ctx?.route ?? null,
|
|
347
|
+
path: ctx?.path ?? null
|
|
348
|
+
};
|
|
349
|
+
if (this.spans.length < MAX_WINDOW_SPANS) this.spans.push(rec);
|
|
350
|
+
if (ctx && ctx.spans.length < MAX_SPANS_PER_REQUEST) ctx.spans.push(rec);
|
|
351
|
+
return rec;
|
|
352
|
+
}
|
|
353
|
+
within(s, fn) {
|
|
354
|
+
const als = this.#als;
|
|
355
|
+
const ctx = als?.getStore();
|
|
356
|
+
return ctx && als ? als.run({
|
|
357
|
+
...ctx,
|
|
358
|
+
span: s.id
|
|
359
|
+
}, fn) : this.#span_als.run(s.id, fn);
|
|
360
|
+
}
|
|
361
|
+
end(s, attrs, error) {
|
|
362
|
+
s.ms = round2(performance.now() - s.start);
|
|
363
|
+
if (attrs) s.attrs = {
|
|
364
|
+
...s.attrs,
|
|
365
|
+
...attrs
|
|
366
|
+
};
|
|
367
|
+
if (error !== void 0) s.error = error instanceof Error ? error.message : String(error);
|
|
368
|
+
}
|
|
369
|
+
tag(key, value) {
|
|
370
|
+
const ctx = this.#als?.getStore();
|
|
371
|
+
if (!ctx) return;
|
|
372
|
+
const tags = ctx.entry.tags ??= {};
|
|
373
|
+
if (key in tags || Object.keys(tags).length < MAX_TAGS) tags[key] = value;
|
|
374
|
+
}
|
|
375
|
+
};
|
|
285
376
|
/** the process's live profiler (the newest): an older one's background timers stand down */
|
|
286
377
|
const LIVE_PROFILER = Symbol.for("ogygia.profiler.live");
|
|
287
378
|
const MAX_NET_PER_REQUEST = 100;
|
|
@@ -290,6 +381,102 @@ const MAX_BACKGROUND_NET = 300;
|
|
|
290
381
|
const RECORDING_MAX_MS = 35e3;
|
|
291
382
|
const RECORDING_HARD_CAP_MS = 3e4;
|
|
292
383
|
const PER_RENDER_TIMEOUT_MS = 15e3;
|
|
384
|
+
/**
|
|
385
|
+
* What the recorder's owner records (the process's one inspector and attribution context). Its
|
|
386
|
+
* lock says who owns it; this says how: `background` (the trap, the sampler, a started window: a
|
|
387
|
+
* request is matched to it by time, and runs in no request context) or `recording` (a page profile
|
|
388
|
+
* or a header-profiled request: every request is attributed). A recording still running past the
|
|
389
|
+
* hard cap is `cut`: it keeps the inspector until it ends, but the site stops paying for its
|
|
390
|
+
* attribution context.
|
|
391
|
+
*/
|
|
392
|
+
const recorder_machine = machine()({
|
|
393
|
+
name: "profiler-recorder",
|
|
394
|
+
initial: "free",
|
|
395
|
+
steps: {
|
|
396
|
+
free: ["background", "recording"],
|
|
397
|
+
background: ["free"],
|
|
398
|
+
recording: ["free", "cut"],
|
|
399
|
+
cut: ["free"]
|
|
400
|
+
},
|
|
401
|
+
after: { recording: {
|
|
402
|
+
ms: 32e3,
|
|
403
|
+
to: "cut",
|
|
404
|
+
data: (s) => ({
|
|
405
|
+
token: s.token,
|
|
406
|
+
since: s.since
|
|
407
|
+
})
|
|
408
|
+
} }
|
|
409
|
+
});
|
|
410
|
+
/**
|
|
411
|
+
* A window you start and stop. `recording` lasts at most WINDOW_MAX_MS (a stop that never comes);
|
|
412
|
+
* leaving it, however, ends the capture's work. `stopping` makes the report, and holds the
|
|
413
|
+
* recorder until it is made.
|
|
414
|
+
*/
|
|
415
|
+
const window_machine = machine()({
|
|
416
|
+
name: "profiler-window",
|
|
417
|
+
initial: "idle",
|
|
418
|
+
steps: {
|
|
419
|
+
idle: ["recording"],
|
|
420
|
+
recording: ["stopping", "idle"],
|
|
421
|
+
stopping: ["idle"]
|
|
422
|
+
},
|
|
423
|
+
after: { recording: {
|
|
424
|
+
ms: WINDOW_MAX_MS,
|
|
425
|
+
to: "idle"
|
|
426
|
+
} }
|
|
427
|
+
});
|
|
428
|
+
/**
|
|
429
|
+
* One background loop (the trap, the always-on sampler): waiting for its next window (the visit
|
|
430
|
+
* owns the timer), taking one, or ended. `caught` is what it kept so far (the trap's slow requests;
|
|
431
|
+
* the sampler keeps none). It ends `done` (the trap kept all it was asked to), `refused` (a window
|
|
432
|
+
* failed: the inspector refused it), or `retired` (a newer profiler took the process over).
|
|
433
|
+
*/
|
|
434
|
+
const loop_machine = machine()({
|
|
435
|
+
name: "profiler-background-loop",
|
|
436
|
+
initial: "idle",
|
|
437
|
+
steps: {
|
|
438
|
+
idle: ["scheduled", "retired"],
|
|
439
|
+
scheduled: [
|
|
440
|
+
"scheduled",
|
|
441
|
+
"capturing",
|
|
442
|
+
"retired"
|
|
443
|
+
],
|
|
444
|
+
capturing: [
|
|
445
|
+
"scheduled",
|
|
446
|
+
"done",
|
|
447
|
+
"refused",
|
|
448
|
+
"retired"
|
|
449
|
+
],
|
|
450
|
+
done: ["retired"],
|
|
451
|
+
refused: ["retired"],
|
|
452
|
+
retired: []
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
const caught_of = (loop) => {
|
|
456
|
+
const s = loop.now;
|
|
457
|
+
return s.kind === "idle" ? 0 : s.caught;
|
|
458
|
+
};
|
|
459
|
+
/** The trap as the dashboard shows it: waiting for or taking a window, stopped by a refusal, or
|
|
460
|
+
* not running (done, or not armed yet). */
|
|
461
|
+
function trap_state(loop) {
|
|
462
|
+
const kind = loop.now.kind;
|
|
463
|
+
if (kind === "scheduled" || kind === "capturing") return "armed";
|
|
464
|
+
return kind === "refused" ? "stopped" : "done";
|
|
465
|
+
}
|
|
466
|
+
/** The span recorders of the windows recording now, newest last. Spans go to the newest; a window
|
|
467
|
+
* that ends hands them back to the one before it, never to nothing while another still records
|
|
468
|
+
* (two profilers in one dev process). */
|
|
469
|
+
const span_windows = [];
|
|
470
|
+
function own_spans(r) {
|
|
471
|
+
span_windows.push(r);
|
|
472
|
+
set_span_recorder(r);
|
|
473
|
+
}
|
|
474
|
+
function disown_spans(r) {
|
|
475
|
+
const at = span_windows.lastIndexOf(r);
|
|
476
|
+
if (at === -1) return;
|
|
477
|
+
span_windows.splice(at, 1);
|
|
478
|
+
set_span_recorder(span_windows.length > 0 ? span_windows[span_windows.length - 1] : null);
|
|
479
|
+
}
|
|
293
480
|
/** the heap's fill (of its limit) past which no timed render is made: one more could end the process */
|
|
294
481
|
const HEAP_STOP = .85;
|
|
295
482
|
/** …and past which the extra renders (what a render keeps, the growth check) are skipped */
|
|
@@ -426,36 +613,64 @@ var Profiler = class {
|
|
|
426
613
|
#sink_url = null;
|
|
427
614
|
#sink_key = null;
|
|
428
615
|
#sink_every = 1e4;
|
|
616
|
+
/** The background work's owner (the sink's timer, the trap's and the sampler's, their windows):
|
|
617
|
+
* closed when a newer profiler takes the process over (`#retire`). */
|
|
618
|
+
#background = scope();
|
|
619
|
+
/** The sink's timer, while it runs: its cancel. */
|
|
429
620
|
#sink_timer = null;
|
|
430
|
-
|
|
621
|
+
/** The sink's posts, one at a time in the order their rows were read: a failed one is tried
|
|
622
|
+
* again before the next goes, so rows reach the sink in order and only once (#293). */
|
|
623
|
+
#sink_posts = outbox({
|
|
624
|
+
name: "profiler sink",
|
|
625
|
+
send: (post, signal) => this.#sink_post(post, signal),
|
|
626
|
+
id: (post) => post.id,
|
|
627
|
+
retry: SINK_POLICY,
|
|
628
|
+
inOrder: true,
|
|
629
|
+
giveUp: (post, error) => {
|
|
630
|
+
this.#sink_last = {
|
|
631
|
+
at: Date.now(),
|
|
632
|
+
ok: false,
|
|
633
|
+
rows: post.rows,
|
|
634
|
+
error: error_text(error)
|
|
635
|
+
};
|
|
636
|
+
warn("devtools_profiler_sink_undelivered", { rows: String(post.rows) }, error);
|
|
637
|
+
},
|
|
638
|
+
waitUntil: (work) => devtools_app()?.keepAlive(work)
|
|
639
|
+
});
|
|
431
640
|
#sink_last = null;
|
|
432
|
-
|
|
433
|
-
#
|
|
434
|
-
#trap_timer = null;
|
|
641
|
+
/** the trap's loop (see `loop_machine`) */
|
|
642
|
+
#trap_loop = loop_machine.create();
|
|
435
643
|
#sample;
|
|
436
644
|
#sampled = [];
|
|
437
|
-
|
|
645
|
+
/** the always-on sampler's loop */
|
|
646
|
+
#sample_loop = loop_machine.create();
|
|
647
|
+
/** why the background loops are off on this host (serverless), when they are */
|
|
438
648
|
#background_note = null;
|
|
439
649
|
/** bytes per asset URL, measured once per process */
|
|
440
|
-
#weights =
|
|
650
|
+
#weights = url_weights();
|
|
441
651
|
/** every page asset weighed so far (hashed assets never change): page-assets.ts */
|
|
442
652
|
#asset_weights = /* @__PURE__ */ new Map();
|
|
443
653
|
#background_net = [];
|
|
444
|
-
|
|
654
|
+
/** each capture's own network log while it records (two only after a reset left one running) */
|
|
655
|
+
#window_nets = /* @__PURE__ */ new Set();
|
|
445
656
|
#inflight = 0;
|
|
446
|
-
#recording_since = 0;
|
|
447
657
|
/** performance.now() when this instance's first request arrived (its cold start, on Lambda) */
|
|
448
658
|
#first_request_pt;
|
|
449
659
|
/** requests this instance has handled */
|
|
450
660
|
#requests_seen = 0;
|
|
451
|
-
#
|
|
661
|
+
#recorder = lock();
|
|
662
|
+
/** what the owner records, and since when (see `recorder_machine`) */
|
|
663
|
+
#holder = recorder_machine.create();
|
|
664
|
+
/** the window you start and stop (see `window_machine`) */
|
|
665
|
+
#win = window_machine.create();
|
|
452
666
|
/** page profiles waiting for a background window to give the recorder back */
|
|
453
667
|
#page_waiting = 0;
|
|
454
668
|
/** the durable report store (SQLite/Redis/Postgres/custom), when configured — else null and the
|
|
455
669
|
* in-memory map is all there is (the prior behaviour) */
|
|
456
670
|
#store = null;
|
|
671
|
+
/** the store's `init`: kept once it succeeds, tried again by the next use after a failure */
|
|
672
|
+
#store_init = null;
|
|
457
673
|
#als = null;
|
|
458
|
-
#init_done = null;
|
|
459
674
|
/** Re-assert the `globalThis.fetch` patch before a profile (self-heal if it was replaced). */
|
|
460
675
|
#ensure_net = null;
|
|
461
676
|
/** wraps Kit's `event.fetch` (in-process same-origin dispatch) with the recorder, per attributed request */
|
|
@@ -479,21 +694,22 @@ var Profiler = class {
|
|
|
479
694
|
this.#disabled = options.enabled === false;
|
|
480
695
|
this.#trap = options.trap && options.trap.over > 0 ? options.trap : null;
|
|
481
696
|
if (options.store) {
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
697
|
+
const store = options.store;
|
|
698
|
+
this.#store = store;
|
|
699
|
+
this.#store_init = flights({
|
|
700
|
+
name: "profiler store init",
|
|
701
|
+
run: async () => {
|
|
702
|
+
await store.init?.();
|
|
703
|
+
}
|
|
486
704
|
});
|
|
705
|
+
this.#store_ready();
|
|
487
706
|
}
|
|
488
707
|
this.#client_cpu = options.clientCpu !== false;
|
|
489
708
|
if (options.sink?.url) {
|
|
490
709
|
this.#sink_url = options.sink.url;
|
|
491
710
|
this.#sink_key = options.sink.key ?? this.secret ?? null;
|
|
492
711
|
this.#sink_every = Math.max(1e3, options.sink.every ?? 1e4);
|
|
493
|
-
if (!Number.isFinite(serverless_work_budget_ms()))
|
|
494
|
-
this.#sink_timer = setInterval(() => void this.#sink_flush(), this.#sink_every);
|
|
495
|
-
this.#sink_timer.unref?.();
|
|
496
|
-
}
|
|
712
|
+
if (!Number.isFinite(serverless_work_budget_ms())) this.#sink_timer = this.#background.every(() => void this.#sink_flush(), this.#sink_every);
|
|
497
713
|
}
|
|
498
714
|
this.#sample = options.sample ? options.sample : null;
|
|
499
715
|
globalThis[LIVE_PROFILER] = this;
|
|
@@ -531,15 +747,19 @@ var Profiler = class {
|
|
|
531
747
|
}
|
|
532
748
|
/** Stand down: the background timers stop (a sink sends what it holds first). */
|
|
533
749
|
#retire() {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
this.#
|
|
537
|
-
this.#
|
|
538
|
-
if (this.#
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
750
|
+
const sinking = this.#sink_timer !== null;
|
|
751
|
+
this.#sink_timer = null;
|
|
752
|
+
for (const loop of [this.#trap_loop, this.#sample_loop]) if (loop.now.kind !== "retired") loop.go("retired", { caught: caught_of(loop) });
|
|
753
|
+
this.#background.close();
|
|
754
|
+
if (sinking) this.#sink_flush();
|
|
755
|
+
}
|
|
756
|
+
/** A background window's length: `ms`, or less when the profiler stands down meanwhile. */
|
|
757
|
+
#pause(ms) {
|
|
758
|
+
return new Promise((done) => {
|
|
759
|
+
const wait = this.#background.child();
|
|
760
|
+
wait.defer(done);
|
|
761
|
+
wait.timeout(() => wait.close(), ms);
|
|
762
|
+
});
|
|
543
763
|
}
|
|
544
764
|
/** Arm the background recorders (once, from the first request). Both need a real server: on a
|
|
545
765
|
* serverless host an instance lives for one request and the inspector is often unavailable. */
|
|
@@ -549,32 +769,52 @@ var Profiler = class {
|
|
|
549
769
|
this.#background_note = "trap / sampling are off on this serverless host (an instance lives for one request).";
|
|
550
770
|
return;
|
|
551
771
|
}
|
|
552
|
-
if (this.#trap &&
|
|
553
|
-
if (this.#sample &&
|
|
772
|
+
if (this.#trap && this.#trap_loop.now.kind === "idle") this.#schedule(this.#trap_loop, () => this.#trap_cycle(), 1e3);
|
|
773
|
+
if (this.#sample && this.#sample_loop.now.kind === "idle") this.#schedule(this.#sample_loop, () => this.#sample_cycle(), (this.#sample.every ?? 60) * 1e3);
|
|
554
774
|
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
775
|
+
/** The loop's next window, `delay_ms` from now: a timer of that wait's own visit. */
|
|
776
|
+
#schedule(loop, cycle, delay_ms) {
|
|
777
|
+
const s = loop.now;
|
|
778
|
+
if (s.kind !== "idle" && s.kind !== "scheduled" && s.kind !== "capturing") return;
|
|
779
|
+
loop.go("scheduled", { caught: caught_of(loop) });
|
|
780
|
+
loop.scope.timeout(() => void cycle(), delay_ms);
|
|
558
781
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
782
|
+
/** A window failed: the loop stops, and says why (on the dashboard, and once as a message). */
|
|
783
|
+
#refused(loop, name, caught, error) {
|
|
784
|
+
if (!loop.move("capturing", "refused", {
|
|
785
|
+
caught,
|
|
786
|
+
error: error_text(error)
|
|
787
|
+
})) return;
|
|
788
|
+
warn("devtools_profiler_background_stopped", { loop: name }, error);
|
|
789
|
+
}
|
|
790
|
+
/** The background loops that stopped, and why the loops are off, when they are: one note. */
|
|
791
|
+
#background_notes() {
|
|
792
|
+
const notes = [];
|
|
793
|
+
if (this.#background_note !== null) notes.push(this.#background_note);
|
|
794
|
+
const t = this.#trap_loop.now;
|
|
795
|
+
if (t.kind === "refused") notes.push(`the trap stopped: ${t.error}.`);
|
|
796
|
+
const s = this.#sample_loop.now;
|
|
797
|
+
if (s.kind === "refused") notes.push(`always-on sampling stopped: ${s.error}.`);
|
|
798
|
+
return notes.length === 0 ? null : notes.join(" ");
|
|
562
799
|
}
|
|
563
800
|
/** One rolling trap window: sample coarsely, then keep the window only if a request in it blew
|
|
564
801
|
* past the threshold. The slowest such request is the report's request; the timeline is its. */
|
|
565
802
|
async #trap_cycle() {
|
|
566
803
|
const t = this.#trap;
|
|
567
|
-
|
|
804
|
+
const loop = this.#trap_loop;
|
|
805
|
+
const s = loop.now;
|
|
806
|
+
if (s.kind !== "scheduled") return;
|
|
568
807
|
if (this.#superseded()) return this.#retire();
|
|
569
808
|
const keep = t.keep ?? 3;
|
|
570
|
-
|
|
571
|
-
if (this.#page_waiting) return
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
809
|
+
const again = (ms) => this.#schedule(loop, () => this.#trap_cycle(), ms);
|
|
810
|
+
if (this.#page_waiting) return again(1e3);
|
|
811
|
+
const token = this.#take("background");
|
|
812
|
+
if (token === null) return again(2e3);
|
|
813
|
+
loop.go("capturing", { caught: s.caught });
|
|
814
|
+
let caught = s.caught;
|
|
575
815
|
try {
|
|
576
816
|
const window_ms = t.window ?? 5e3;
|
|
577
|
-
const cap = await this.#capture_window((t.interval ?? 10) * 1e3, () =>
|
|
817
|
+
const cap = await this.#capture_window((t.interval ?? 10) * 1e3, () => this.#pause(window_ms), { light: true });
|
|
578
818
|
const slow = this.#ring.filter((e) => !e.internal && e.pt !== void 0 && e.ts >= cap.t0 && e.ts + e.ms <= cap.t1 && e.ms >= t.over).sort((x, y) => y.ms - x.ms)[0];
|
|
579
819
|
if (slow) {
|
|
580
820
|
cap.window = {
|
|
@@ -607,31 +847,31 @@ var Profiler = class {
|
|
|
607
847
|
});
|
|
608
848
|
this.#sink_flush();
|
|
609
849
|
}
|
|
610
|
-
|
|
850
|
+
caught++;
|
|
611
851
|
}
|
|
612
|
-
} catch {
|
|
613
|
-
this.#
|
|
852
|
+
} catch (error) {
|
|
853
|
+
return this.#refused(loop, "trap", caught, error);
|
|
614
854
|
} finally {
|
|
615
|
-
this.#
|
|
616
|
-
this.#recording_since = 0;
|
|
617
|
-
this.#release_als();
|
|
618
|
-
this.#release_recorder();
|
|
855
|
+
this.#free(token);
|
|
619
856
|
}
|
|
620
|
-
if (
|
|
857
|
+
if (caught >= keep) loop.move("capturing", "done", { caught });
|
|
858
|
+
else again(1e3);
|
|
621
859
|
}
|
|
622
860
|
/** One always-on sample: a short coarse window folded into the rolling hot-functions table. */
|
|
623
861
|
async #sample_cycle() {
|
|
624
862
|
const s = this.#sample;
|
|
625
|
-
|
|
863
|
+
const loop = this.#sample_loop;
|
|
864
|
+
if (loop.now.kind !== "scheduled") return;
|
|
626
865
|
if (this.#superseded()) return this.#retire();
|
|
627
866
|
const every_ms = (s.every ?? 60) * 1e3;
|
|
628
|
-
|
|
629
|
-
if (
|
|
630
|
-
|
|
631
|
-
|
|
867
|
+
const again = (ms) => this.#schedule(loop, () => this.#sample_cycle(), ms);
|
|
868
|
+
if (this.#page_waiting) return again(Math.min(every_ms, 2e3));
|
|
869
|
+
const token = this.#take("background");
|
|
870
|
+
if (token === null) return again(Math.min(every_ms, 5e3));
|
|
871
|
+
loop.go("capturing", { caught: 0 });
|
|
632
872
|
try {
|
|
633
873
|
const window_ms = s.window ?? 2e3;
|
|
634
|
-
const cap = await this.#capture_window((s.interval ?? 10) * 1e3, () =>
|
|
874
|
+
const cap = await this.#capture_window((s.interval ?? 10) * 1e3, () => this.#pause(window_ms), { light: true });
|
|
635
875
|
const resolver = await this.#make_resolver();
|
|
636
876
|
const a = analyze(cap.profile, resolver, void 0, void 0, void 0, this.#module_hint);
|
|
637
877
|
this.#sampled.push({
|
|
@@ -663,15 +903,12 @@ var Profiler = class {
|
|
|
663
903
|
}))
|
|
664
904
|
});
|
|
665
905
|
}
|
|
666
|
-
} catch {
|
|
667
|
-
return;
|
|
906
|
+
} catch (error) {
|
|
907
|
+
return this.#refused(loop, "always-on sampler", 0, error);
|
|
668
908
|
} finally {
|
|
669
|
-
this.#
|
|
670
|
-
this.#recording_since = 0;
|
|
671
|
-
this.#release_als();
|
|
672
|
-
this.#release_recorder();
|
|
909
|
+
this.#free(token);
|
|
673
910
|
}
|
|
674
|
-
|
|
911
|
+
again(every_ms);
|
|
675
912
|
}
|
|
676
913
|
/** The always-on table: every kept window folded, hot functions by summed self time. */
|
|
677
914
|
#sampled_summary() {
|
|
@@ -704,10 +941,10 @@ var Profiler = class {
|
|
|
704
941
|
};
|
|
705
942
|
}
|
|
706
943
|
#collect_window(call) {
|
|
707
|
-
|
|
944
|
+
for (const net of this.#window_nets) if (net.length < MAX_WINDOW_NET) net.push(call);
|
|
708
945
|
}
|
|
709
946
|
/** An attributed request's context: its own calls (capped), each also offered to the window. */
|
|
710
|
-
#request_ctx(entry) {
|
|
947
|
+
#request_ctx(entry, owner) {
|
|
711
948
|
const collect_window = (call) => this.#collect_window(call);
|
|
712
949
|
return {
|
|
713
950
|
entry,
|
|
@@ -715,6 +952,7 @@ var Profiler = class {
|
|
|
715
952
|
spans: [],
|
|
716
953
|
route: entry.route,
|
|
717
954
|
path: entry.path,
|
|
955
|
+
owner,
|
|
718
956
|
on_net(call) {
|
|
719
957
|
if (this.net.length < MAX_NET_PER_REQUEST) this.net.push(call);
|
|
720
958
|
collect_window(call);
|
|
@@ -722,7 +960,12 @@ var Profiler = class {
|
|
|
722
960
|
};
|
|
723
961
|
}
|
|
724
962
|
#init() {
|
|
725
|
-
return this.#init_done
|
|
963
|
+
return this.#init_done.get();
|
|
964
|
+
}
|
|
965
|
+
/** The mount's one-time setup: the background timers and the network capture (it never fails). */
|
|
966
|
+
#init_done = flights({
|
|
967
|
+
name: "profiler init",
|
|
968
|
+
run: async () => {
|
|
726
969
|
this.#arm_background();
|
|
727
970
|
if (!this.want_network) return;
|
|
728
971
|
try {
|
|
@@ -747,29 +990,69 @@ var Profiler = class {
|
|
|
747
990
|
});
|
|
748
991
|
this.#ensure_net = ensure_fetch_patched;
|
|
749
992
|
} catch {}
|
|
750
|
-
}
|
|
751
|
-
}
|
|
993
|
+
}
|
|
994
|
+
});
|
|
752
995
|
/** A recording is "active" only if one started recently — a run abandoned by a frozen/killed
|
|
753
996
|
* serverless invocation ages out after the cap instead of wedging the profiler forever. */
|
|
754
997
|
#recording_active() {
|
|
755
|
-
|
|
998
|
+
const s = this.#holder.now;
|
|
999
|
+
return s.kind === "recording" && Date.now() - s.since < RECORDING_MAX_MS;
|
|
756
1000
|
}
|
|
757
|
-
/** Take the recorder
|
|
1001
|
+
/** Take the recorder without waiting: its token if it was free (now ours), null if busy. Recordings
|
|
758
1002
|
* serialize PER WORKER — one V8 inspector CPU/heap sampler + one attribution context per process — so
|
|
759
1003
|
* a second recording on the same process is refused, NOT queued in memory: on serverless the process
|
|
760
1004
|
* can be recycled at any moment (Amplify freezes a worker after ~30 s), so a waiter parked in memory
|
|
761
1005
|
* is unreliable, and it isn't needed — a retry lands on a different, free worker. The "queue" is the
|
|
762
|
-
* CLIENT polling `/page` until a worker says yes (see RunView).
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
return
|
|
1006
|
+
* CLIENT polling `/page` until a worker says yes (see RunView). A recording that runs past the hard
|
|
1007
|
+
* cap is cut (its own time, `recorder_machine`): the site stops paying for its context. */
|
|
1008
|
+
#take(kind) {
|
|
1009
|
+
const token = this.#recorder.take();
|
|
1010
|
+
if (token === null) return null;
|
|
1011
|
+
this.#holder.go(kind, {
|
|
1012
|
+
token,
|
|
1013
|
+
since: Date.now()
|
|
1014
|
+
});
|
|
1015
|
+
this.#holder.scope.defer(() => this.#release_als());
|
|
1016
|
+
return token;
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* The answer to a recording asked for while the recorder is someone else's: a 409 the client
|
|
1020
|
+
* retries, which says who holds it, since when, and whether that holder is STALE: a page recording
|
|
1021
|
+
* past its cap (`cut`, or past RECORDING_MAX_MS by its start), whose request died before its
|
|
1022
|
+
* cleanup ran. Only a stale holder is a reset's to take: a live one (a window someone started, a
|
|
1023
|
+
* page profile still running, the trap) is someone's recording (#290).
|
|
1024
|
+
*/
|
|
1025
|
+
#busy(message) {
|
|
1026
|
+
const s = this.#holder.now;
|
|
1027
|
+
const holder = s.kind === "free" ? null : {
|
|
1028
|
+
kind: this.#win.now.kind !== "idle" ? "window" : s.kind,
|
|
1029
|
+
age_ms: Date.now() - s.since,
|
|
1030
|
+
stale: s.kind === "cut" || s.kind === "recording" && !this.#recording_active()
|
|
1031
|
+
};
|
|
1032
|
+
return new Response(JSON.stringify({
|
|
1033
|
+
busy: true,
|
|
1034
|
+
message,
|
|
1035
|
+
holder
|
|
1036
|
+
}), {
|
|
1037
|
+
status: 409,
|
|
1038
|
+
headers: {
|
|
1039
|
+
"content-type": "application/json; charset=utf-8",
|
|
1040
|
+
"retry-after": "2",
|
|
1041
|
+
"cache-control": "no-store"
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
767
1044
|
}
|
|
768
|
-
/**
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1045
|
+
/** `token` still owns the recorder (a reset, or the next owner, took it otherwise). */
|
|
1046
|
+
#holds(token) {
|
|
1047
|
+
const s = this.#holder.now;
|
|
1048
|
+
return s.kind !== "free" && s.token === token;
|
|
1049
|
+
}
|
|
1050
|
+
/** Free the recorder, only for the token that owns it: false for one a reset took it from. Called
|
|
1051
|
+
* only when the recording is truly finished (its inspector session closed). */
|
|
1052
|
+
#free(token) {
|
|
1053
|
+
if (!this.#recorder.free(token)) return false;
|
|
1054
|
+
this.#holder.go("free");
|
|
1055
|
+
return true;
|
|
773
1056
|
}
|
|
774
1057
|
/** Production: detach the profiler's AsyncLocalStorage once a recording ends. An ALS that has
|
|
775
1058
|
* run once stays enabled — every async hop in the process keeps paying a store copy for it — until
|
|
@@ -821,10 +1104,9 @@ var Profiler = class {
|
|
|
821
1104
|
});
|
|
822
1105
|
};
|
|
823
1106
|
sample_mem();
|
|
824
|
-
const
|
|
825
|
-
|
|
1107
|
+
const sampling = scope();
|
|
1108
|
+
sampling.every(sample_mem, 250);
|
|
826
1109
|
const heap_series = [];
|
|
827
|
-
let heap_timer;
|
|
828
1110
|
if (!opts.light) try {
|
|
829
1111
|
const v8 = await import("node:v8");
|
|
830
1112
|
const sample_heap = () => {
|
|
@@ -834,62 +1116,22 @@ var Profiler = class {
|
|
|
834
1116
|
mb: v8.getHeapStatistics().used_heap_size / 1048576
|
|
835
1117
|
});
|
|
836
1118
|
};
|
|
837
|
-
|
|
838
|
-
heap_timer.unref?.();
|
|
1119
|
+
sampling.every(sample_heap, HEAP_SERIES_PERIOD_MS);
|
|
839
1120
|
} catch {}
|
|
840
|
-
|
|
1121
|
+
const owner = {};
|
|
1122
|
+
const window_net = [];
|
|
841
1123
|
const netmod = await import("./net.js").catch(() => null);
|
|
842
|
-
if (!opts.light) netmod?.set_stack_capture(true);
|
|
843
|
-
const spans = [];
|
|
844
|
-
let span_seq = 0;
|
|
845
|
-
const als = this.#als;
|
|
846
1124
|
const { AsyncLocalStorage: SpanALS } = await import("node:async_hooks");
|
|
847
1125
|
const span_als = new SpanALS();
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
const ctx = als?.getStore();
|
|
851
|
-
const parent = ctx?.span ?? span_als.getStore();
|
|
852
|
-
const rec = {
|
|
853
|
-
id: ++span_seq,
|
|
854
|
-
name: String(name).slice(0, 120),
|
|
855
|
-
start: perf_hooks.performance.now(),
|
|
856
|
-
ms: -1,
|
|
857
|
-
...attrs ? { attrs: { ...attrs } } : {},
|
|
858
|
-
...parent !== void 0 ? { parent } : {},
|
|
859
|
-
caller_site: netmod?.nearest_app_site(),
|
|
860
|
-
route: ctx?.route ?? null,
|
|
861
|
-
path: ctx?.path ?? null
|
|
862
|
-
};
|
|
863
|
-
if (spans.length < MAX_WINDOW_SPANS) spans.push(rec);
|
|
864
|
-
if (ctx && ctx.spans.length < MAX_SPANS_PER_REQUEST) ctx.spans.push(rec);
|
|
865
|
-
return rec;
|
|
866
|
-
},
|
|
867
|
-
within: (s, fn) => {
|
|
868
|
-
const ctx = als?.getStore();
|
|
869
|
-
return ctx && als ? als.run({
|
|
870
|
-
...ctx,
|
|
871
|
-
span: s.id
|
|
872
|
-
}, fn) : span_als.run(s.id, fn);
|
|
873
|
-
},
|
|
874
|
-
end: (s, attrs, error) => {
|
|
875
|
-
s.ms = round2(perf_hooks.performance.now() - s.start);
|
|
876
|
-
if (attrs) s.attrs = {
|
|
877
|
-
...s.attrs,
|
|
878
|
-
...attrs
|
|
879
|
-
};
|
|
880
|
-
if (error !== void 0) s.error = error instanceof Error ? error.message : String(error);
|
|
881
|
-
},
|
|
882
|
-
tag: (key, value) => {
|
|
883
|
-
const ctx = als?.getStore();
|
|
884
|
-
if (!ctx) return;
|
|
885
|
-
const tags = ctx.entry.tags ??= {};
|
|
886
|
-
if (key in tags || Object.keys(tags).length < MAX_TAGS) tags[key] = value;
|
|
887
|
-
}
|
|
888
|
-
});
|
|
1126
|
+
const window_spans = new WindowSpans(this.#als, span_als, netmod);
|
|
1127
|
+
const { spans } = window_spans;
|
|
889
1128
|
const iomod = opts.light ? null : await import("./async-io.js").catch(() => null);
|
|
890
|
-
let io_rec = iomod ? await iomod.
|
|
1129
|
+
let io_rec = iomod ? await iomod.IoRecorder.start() : null;
|
|
891
1130
|
let heap_head = null;
|
|
892
|
-
|
|
1131
|
+
const release_detail = hold_request_stats_detail();
|
|
1132
|
+
this.#window_nets.add(window_net);
|
|
1133
|
+
if (!opts.light) netmod?.set_stack_capture(owner, true);
|
|
1134
|
+
own_spans(window_spans);
|
|
893
1135
|
try {
|
|
894
1136
|
await session.post("Profiler.enable");
|
|
895
1137
|
await session.post("Profiler.setSamplingInterval", { interval: interval_us });
|
|
@@ -911,7 +1153,7 @@ var Profiler = class {
|
|
|
911
1153
|
});
|
|
912
1154
|
heap_on = true;
|
|
913
1155
|
} catch (e) {
|
|
914
|
-
if (process.env.OGYGIA_PROFILER_DEBUG) console.error(
|
|
1156
|
+
if (process.env.OGYGIA_PROFILER_DEBUG) console.error(new OgygiaError("devtools_profiler_alloc_sampling_unavailable"), e);
|
|
915
1157
|
await session.post("HeapProfiler.startSampling", { samplingInterval: 16384 });
|
|
916
1158
|
}
|
|
917
1159
|
}
|
|
@@ -948,7 +1190,7 @@ var Profiler = class {
|
|
|
948
1190
|
const cpu = process.cpuUsage(cpu0);
|
|
949
1191
|
const elu = perf_hooks.performance.eventLoopUtilization(elu0);
|
|
950
1192
|
const duration_ms = t1 - t0;
|
|
951
|
-
const net =
|
|
1193
|
+
const net = window_net;
|
|
952
1194
|
for (const s of spans) if (s.ms < 0) s.open = true;
|
|
953
1195
|
return {
|
|
954
1196
|
profile,
|
|
@@ -986,18 +1228,17 @@ var Profiler = class {
|
|
|
986
1228
|
})) } : {}
|
|
987
1229
|
};
|
|
988
1230
|
} finally {
|
|
989
|
-
|
|
990
|
-
if (heap_timer) clearInterval(heap_timer);
|
|
1231
|
+
sampling.close();
|
|
991
1232
|
histogram.disable();
|
|
992
|
-
|
|
1233
|
+
disown_spans(window_spans);
|
|
993
1234
|
span_als.disable();
|
|
994
|
-
|
|
995
|
-
netmod?.set_stack_capture(false);
|
|
1235
|
+
release_detail();
|
|
1236
|
+
netmod?.set_stack_capture(owner, false);
|
|
996
1237
|
io_rec?.stop();
|
|
997
1238
|
try {
|
|
998
1239
|
observer?.disconnect();
|
|
999
1240
|
} catch {}
|
|
1000
|
-
this.#window_net
|
|
1241
|
+
this.#window_nets.delete(window_net);
|
|
1001
1242
|
try {
|
|
1002
1243
|
session.disconnect();
|
|
1003
1244
|
} catch {}
|
|
@@ -1326,7 +1567,7 @@ var Profiler = class {
|
|
|
1326
1567
|
});
|
|
1327
1568
|
gc_attr.window_offset_ms = window_offset_ms;
|
|
1328
1569
|
} catch (e) {
|
|
1329
|
-
if (process.env.OGYGIA_PROFILER_DEBUG) console.error(
|
|
1570
|
+
if (process.env.OGYGIA_PROFILER_DEBUG) console.error(new OgygiaError("devtools_profiler_gc_attribution_failed"), e);
|
|
1330
1571
|
gc_attr = void 0;
|
|
1331
1572
|
}
|
|
1332
1573
|
{
|
|
@@ -1587,7 +1828,11 @@ var Profiler = class {
|
|
|
1587
1828
|
if (this.ui_enabled && this.secret && !this.dev) {
|
|
1588
1829
|
const after_login = ctx.url.searchParams.has(AFTER_LOGIN_PARAM);
|
|
1589
1830
|
const diag = this.#cookie_diagnosis(ctx.event);
|
|
1590
|
-
if (after_login || diag.pairs > 0)
|
|
1831
|
+
if (after_login || diag.pairs > 0) warn("devtools_profiler_session_rejected", {
|
|
1832
|
+
path: ctx.url.pathname,
|
|
1833
|
+
diagnosis: JSON.stringify(diag),
|
|
1834
|
+
reason: describe_cookie_diagnosis(diag)
|
|
1835
|
+
});
|
|
1591
1836
|
const target = new URL(ctx.url);
|
|
1592
1837
|
target.searchParams.delete(AFTER_LOGIN_PARAM);
|
|
1593
1838
|
const next = encodeURIComponent(target.pathname + target.search);
|
|
@@ -1610,7 +1855,7 @@ var Profiler = class {
|
|
|
1610
1855
|
window_stop: (c) => this.#window_stop(c),
|
|
1611
1856
|
reset: (c) => this.#reset(c),
|
|
1612
1857
|
status: () => ({
|
|
1613
|
-
recording: this.#
|
|
1858
|
+
recording: this.#recorder.held,
|
|
1614
1859
|
inflight: this.#inflight,
|
|
1615
1860
|
rss_mb: Math.round(process.memoryUsage().rss / 1048576),
|
|
1616
1861
|
reports: this.#reports.size
|
|
@@ -2486,10 +2731,9 @@ var Profiler = class {
|
|
|
2486
2731
|
* profile saw with a `.svelte` source on this machine, and the islands (their keys from the
|
|
2487
2732
|
* build), each with the page.data keys it reads — joined to the loads' keys and the seed. */
|
|
2488
2733
|
/** every `.svelte` under the app's `src/`, relative to it, listed once (null: no src here) */
|
|
2489
|
-
#svelte_files
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
this.#svelte_files ??= (async () => {
|
|
2734
|
+
#svelte_files = flights({
|
|
2735
|
+
name: "profiler svelte files",
|
|
2736
|
+
run: async () => {
|
|
2493
2737
|
try {
|
|
2494
2738
|
const fs = await import("node:fs");
|
|
2495
2739
|
const path = await import("node:path");
|
|
@@ -2508,8 +2752,12 @@ var Profiler = class {
|
|
|
2508
2752
|
const carried = embedded_files().filter((f) => f.endsWith(".svelte"));
|
|
2509
2753
|
return carried.length ? carried : null;
|
|
2510
2754
|
}
|
|
2511
|
-
}
|
|
2512
|
-
|
|
2755
|
+
}
|
|
2756
|
+
});
|
|
2757
|
+
/** The `src/`-relative paths of `<name>.svelte` files (a component is named after its file). */
|
|
2758
|
+
async #component_files(name) {
|
|
2759
|
+
const all = await this.#svelte_files.get();
|
|
2760
|
+
if (all === STALE) return [];
|
|
2513
2761
|
const leaf = "/" + name + ".svelte";
|
|
2514
2762
|
return all ? all.filter((f) => f === name + ".svelte" || f.endsWith(leaf)) : [];
|
|
2515
2763
|
}
|
|
@@ -2918,7 +3166,7 @@ var Profiler = class {
|
|
|
2918
3166
|
})),
|
|
2919
3167
|
site_fixes: this.#site_fixes(),
|
|
2920
3168
|
holes: hole_slots(this.#ring),
|
|
2921
|
-
recording: this.#
|
|
3169
|
+
recording: this.#recorder.held,
|
|
2922
3170
|
dev: this.dev,
|
|
2923
3171
|
rss_mb: Math.round(process.memoryUsage().rss / 1048576),
|
|
2924
3172
|
inflight: this.#inflight,
|
|
@@ -2934,12 +3182,12 @@ var Profiler = class {
|
|
|
2934
3182
|
trap: this.#trap ? {
|
|
2935
3183
|
over: this.#trap.over,
|
|
2936
3184
|
window_ms: this.#trap.window ?? 5e3,
|
|
2937
|
-
caught: this.#
|
|
3185
|
+
caught: caught_of(this.#trap_loop),
|
|
2938
3186
|
keep: this.#trap.keep ?? 3,
|
|
2939
|
-
|
|
3187
|
+
state: trap_state(this.#trap_loop)
|
|
2940
3188
|
} : null,
|
|
2941
3189
|
sampled: this.#sampled_summary(),
|
|
2942
|
-
background_note: this.#
|
|
3190
|
+
background_note: this.#background_notes()
|
|
2943
3191
|
};
|
|
2944
3192
|
}
|
|
2945
3193
|
#run_page(ctx) {
|
|
@@ -2967,7 +3215,6 @@ var Profiler = class {
|
|
|
2967
3215
|
...against && against.length <= 32 ? { against } : {}
|
|
2968
3216
|
};
|
|
2969
3217
|
}
|
|
2970
|
-
#window = null;
|
|
2971
3218
|
async #window_sign(nonce) {
|
|
2972
3219
|
const { createHmac } = await import("node:crypto");
|
|
2973
3220
|
return createHmac("sha256", this.secret || "ogygia-dev-window").update("window:" + nonce).digest("hex").slice(0, 20);
|
|
@@ -2998,8 +3245,10 @@ var Profiler = class {
|
|
|
2998
3245
|
at: Date.now(),
|
|
2999
3246
|
instance: this.#instance_id
|
|
3000
3247
|
};
|
|
3248
|
+
const store = await this.#store_ready();
|
|
3249
|
+
if (!store?.putVisit) return;
|
|
3001
3250
|
try {
|
|
3002
|
-
await
|
|
3251
|
+
await store.putVisit({
|
|
3003
3252
|
key: `${WINDOW_PAGE_PREFIX}${id}\0${rec.at}\0${Math.random().toString(36).slice(2, 8)}`,
|
|
3004
3253
|
page: WINDOW_PAGE_PREFIX + id,
|
|
3005
3254
|
at: rec.at,
|
|
@@ -3009,9 +3258,10 @@ var Profiler = class {
|
|
|
3009
3258
|
}
|
|
3010
3259
|
/** The requests every instance wrote for a window. */
|
|
3011
3260
|
async #window_requests(id) {
|
|
3012
|
-
|
|
3261
|
+
const store = await this.#store_ready();
|
|
3262
|
+
if (!store?.listVisits) return [];
|
|
3013
3263
|
try {
|
|
3014
|
-
return (await
|
|
3264
|
+
return (await store.listVisits(WINDOW_PAGE_PREFIX + id, MAX_VISITS_PER_KEY)).map((r) => JSON.parse(r.visit)).sort((a, b) => a.at - b.at);
|
|
3015
3265
|
} catch {
|
|
3016
3266
|
return [];
|
|
3017
3267
|
}
|
|
@@ -3019,50 +3269,30 @@ var Profiler = class {
|
|
|
3019
3269
|
/** This instance, told apart from the others in a window's requests. */
|
|
3020
3270
|
#instance_id = Math.random().toString(36).slice(2, 10);
|
|
3021
3271
|
async #window_start(ctx) {
|
|
3022
|
-
const busy = (message) =>
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
"retry-after": "2",
|
|
3030
|
-
"cache-control": "no-store"
|
|
3031
|
-
}
|
|
3032
|
-
});
|
|
3033
|
-
if (this.#window) return busy("A window is already recording: stop it first.");
|
|
3034
|
-
if (this.#recorder_busy && this.#trap_running) {
|
|
3035
|
-
this.#page_waiting++;
|
|
3036
|
-
try {
|
|
3037
|
-
const until = Date.now() + PAGE_WAITS_FOR_BACKGROUND_MS;
|
|
3038
|
-
while (this.#recorder_busy && this.#trap_running && Date.now() < until) await new Promise((r) => setTimeout(r, 50));
|
|
3039
|
-
} finally {
|
|
3040
|
-
this.#page_waiting--;
|
|
3041
|
-
}
|
|
3042
|
-
}
|
|
3043
|
-
if (!this.#try_acquire_recorder()) return busy("Another recording holds the recorder. Try again in a moment.");
|
|
3044
|
-
this.#recording_since = Date.now();
|
|
3045
|
-
this.#trap_running = true;
|
|
3272
|
+
const busy = (message) => this.#busy(message);
|
|
3273
|
+
if (this.#win.now.kind !== "idle") return busy("A window is already recording: stop it first.");
|
|
3274
|
+
await this.#wait_for_background();
|
|
3275
|
+
const nonce = Math.random().toString(36).slice(2, 12) + Date.now().toString(36);
|
|
3276
|
+
const id = `${nonce}.${await this.#window_sign(nonce)}`;
|
|
3277
|
+
const token = this.#take("background");
|
|
3278
|
+
if (token === null) return busy("Another recording holds the recorder. Try again in a moment.");
|
|
3046
3279
|
this.#ensure_net?.();
|
|
3047
3280
|
let stop;
|
|
3048
3281
|
const stopped = new Promise((r) => stop = r);
|
|
3049
|
-
const nonce = Math.random().toString(36).slice(2, 12) + Date.now().toString(36);
|
|
3050
|
-
const id = `${nonce}.${await this.#window_sign(nonce)}`;
|
|
3051
|
-
const timer = setTimeout(() => {
|
|
3052
|
-
stop();
|
|
3053
|
-
done.finally(() => {
|
|
3054
|
-
if (this.#window?.id === id) this.#window_release();
|
|
3055
|
-
});
|
|
3056
|
-
}, WINDOW_MAX_MS);
|
|
3057
|
-
timer.unref?.();
|
|
3058
3282
|
const done = this.#capture_window(Math.min(this.sample_interval, 1e3), () => stopped, { light: true });
|
|
3059
|
-
|
|
3060
|
-
this.#window = {
|
|
3283
|
+
this.#win.go("recording", {
|
|
3061
3284
|
id,
|
|
3062
|
-
|
|
3063
|
-
done
|
|
3064
|
-
|
|
3285
|
+
token,
|
|
3286
|
+
done
|
|
3287
|
+
});
|
|
3288
|
+
const mine = this.#win.now;
|
|
3289
|
+
this.#win.scope.defer(stop);
|
|
3290
|
+
const settled = () => {
|
|
3291
|
+
if (this.#win.now === mine) this.#win.go("idle");
|
|
3292
|
+
const now = this.#win.now;
|
|
3293
|
+
if (now.kind !== "stopping" || now.token !== token) this.#free(token);
|
|
3065
3294
|
};
|
|
3295
|
+
done.then(settled, settled);
|
|
3066
3296
|
return ctx.json({
|
|
3067
3297
|
ok: true,
|
|
3068
3298
|
id,
|
|
@@ -3071,18 +3301,31 @@ var Profiler = class {
|
|
|
3071
3301
|
max_ms: WINDOW_MAX_MS
|
|
3072
3302
|
});
|
|
3073
3303
|
}
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
this.#
|
|
3080
|
-
this.#
|
|
3304
|
+
/** A page profile or a window waits (a few seconds at most) for a background window (the trap,
|
|
3305
|
+
* the sampler, a started window) to give the recorder back. The trap and the sampler do not
|
|
3306
|
+
* start again while one waits. The wait is its request's: once the request ends, it waits no
|
|
3307
|
+
* more. */
|
|
3308
|
+
async #wait_for_background() {
|
|
3309
|
+
if (this.#holder.now.kind !== "background") return;
|
|
3310
|
+
this.#page_waiting++;
|
|
3311
|
+
const wait = scope(current_request_scope() ?? void 0);
|
|
3312
|
+
try {
|
|
3313
|
+
wait.timeout(() => wait.close(), PAGE_WAITS_FOR_BACKGROUND_MS);
|
|
3314
|
+
await wait.task(() => this.#holder.wait("free"));
|
|
3315
|
+
} finally {
|
|
3316
|
+
wait.close();
|
|
3317
|
+
this.#page_waiting--;
|
|
3318
|
+
}
|
|
3081
3319
|
}
|
|
3082
3320
|
async #window_stop(ctx) {
|
|
3083
3321
|
const asked = ctx.event ? await this.#window_id_of(ctx.event.request) : null;
|
|
3084
|
-
const w = this.#
|
|
3085
|
-
|
|
3322
|
+
const w = this.#win.now;
|
|
3323
|
+
const ours = w.kind !== "idle" && (!asked || w.id === asked);
|
|
3324
|
+
if (ours && w.kind === "stopping") {
|
|
3325
|
+
const a = await w.answer;
|
|
3326
|
+
return ctx.json(a.body, { status: a.status });
|
|
3327
|
+
}
|
|
3328
|
+
if (!ours || w.kind !== "recording") {
|
|
3086
3329
|
if (asked && this.#store?.listVisits) {
|
|
3087
3330
|
const reqs = await this.#window_requests(asked);
|
|
3088
3331
|
const busy = round2(reqs.reduce((a, r) => a + (r.cpu_ms || 0), 0));
|
|
@@ -3112,16 +3355,33 @@ var Profiler = class {
|
|
|
3112
3355
|
}
|
|
3113
3356
|
return ctx.json({ error: "No window is recording (start one with POST /window/start)." }, { status: 400 });
|
|
3114
3357
|
}
|
|
3115
|
-
w
|
|
3116
|
-
|
|
3358
|
+
const answer = this.#window_answer(w);
|
|
3359
|
+
this.#win.move("recording", "stopping", {
|
|
3360
|
+
id: w.id,
|
|
3361
|
+
token: w.token,
|
|
3362
|
+
answer
|
|
3363
|
+
});
|
|
3364
|
+
const a = await answer;
|
|
3365
|
+
return ctx.json(a.body, { status: a.status });
|
|
3366
|
+
}
|
|
3367
|
+
/** The stopped window's report, made once; the window holds the recorder until it is made. */
|
|
3368
|
+
async #window_answer(w) {
|
|
3117
3369
|
try {
|
|
3118
|
-
|
|
3370
|
+
return await this.#window_report(w.id, await w.done);
|
|
3119
3371
|
} catch (e) {
|
|
3120
|
-
|
|
3121
|
-
|
|
3372
|
+
return {
|
|
3373
|
+
status: 500,
|
|
3374
|
+
body: { error: `The window failed: ${e instanceof Error ? e.message : String(e)}` }
|
|
3375
|
+
};
|
|
3376
|
+
} finally {
|
|
3377
|
+
const now = this.#win.now;
|
|
3378
|
+
if (now.kind === "stopping" && now.token === w.token) this.#win.go("idle");
|
|
3379
|
+
this.#free(w.token);
|
|
3122
3380
|
}
|
|
3381
|
+
}
|
|
3382
|
+
async #window_report(window_id, cap) {
|
|
3123
3383
|
const own = `${this.base}/`;
|
|
3124
|
-
const shared = await this.#window_requests(
|
|
3384
|
+
const shared = await this.#window_requests(window_id);
|
|
3125
3385
|
const others = shared.filter((e) => e.instance !== this.#instance_id && !e.path.startsWith(own) && e.path !== this.base);
|
|
3126
3386
|
const requests = [...this.#ring.filter((e) => !e.internal && e.ts >= cap.t0 && e.ts <= cap.t1 && !e.path.startsWith(own) && e.path !== this.base).map((e) => ({
|
|
3127
3387
|
method: e.method,
|
|
@@ -3132,64 +3392,44 @@ var Profiler = class {
|
|
|
3132
3392
|
cpu_ms: e.cpu_ms,
|
|
3133
3393
|
instance: this.#instance_id
|
|
3134
3394
|
})), ...others];
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3395
|
+
const id = await this.#finish_report(cap, {
|
|
3396
|
+
trigger: "window",
|
|
3397
|
+
...requests[0] ? { request: {
|
|
3398
|
+
method: requests[0].method,
|
|
3399
|
+
path: requests[0].path,
|
|
3400
|
+
route: requests[0].route,
|
|
3401
|
+
ms: requests[0].ms
|
|
3402
|
+
} } : {}
|
|
3403
|
+
});
|
|
3404
|
+
const s = this.#reports.get(id);
|
|
3405
|
+
return {
|
|
3406
|
+
status: 200,
|
|
3407
|
+
body: {
|
|
3147
3408
|
...report_json(s.analysis, s.meta, this.base, this.#report_extras(s)),
|
|
3148
3409
|
window: {
|
|
3149
3410
|
ms: round2(cap.t1 - cap.t0),
|
|
3150
3411
|
requests,
|
|
3151
3412
|
...shared.length ? { instances: new Set(requests.map((r) => r.instance)).size } : {}
|
|
3152
3413
|
}
|
|
3153
|
-
}
|
|
3154
|
-
}
|
|
3155
|
-
this.#window_release();
|
|
3156
|
-
}
|
|
3414
|
+
}
|
|
3415
|
+
};
|
|
3157
3416
|
}
|
|
3158
3417
|
#reset(ctx) {
|
|
3159
|
-
this.#
|
|
3418
|
+
if (this.#holder.now.kind !== "free") {
|
|
3419
|
+
this.#recorder.free(this.#recorder.seize());
|
|
3420
|
+
this.#holder.go("free");
|
|
3421
|
+
}
|
|
3422
|
+
if (this.#win.now.kind !== "idle") this.#win.go("idle");
|
|
3160
3423
|
this.#release_als();
|
|
3161
|
-
this.#recorder_busy = false;
|
|
3162
3424
|
return ctx.redirect(this.base);
|
|
3163
3425
|
}
|
|
3164
3426
|
async #record_page(ctx, replay) {
|
|
3165
3427
|
const event = ctx.event;
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
const until = Date.now() + PAGE_WAITS_FOR_BACKGROUND_MS;
|
|
3170
|
-
while (this.#recorder_busy && this.#trap_running && Date.now() < until) await new Promise((r) => setTimeout(r, 50));
|
|
3171
|
-
} finally {
|
|
3172
|
-
this.#page_waiting--;
|
|
3173
|
-
}
|
|
3174
|
-
}
|
|
3175
|
-
if (!this.#try_acquire_recorder()) return new Response(JSON.stringify({
|
|
3176
|
-
busy: true,
|
|
3177
|
-
message: "A profile is already running on this worker. Waiting for it to finish (or for a free worker)."
|
|
3178
|
-
}), {
|
|
3179
|
-
status: 409,
|
|
3180
|
-
headers: {
|
|
3181
|
-
"content-type": "application/json; charset=utf-8",
|
|
3182
|
-
"retry-after": "2",
|
|
3183
|
-
"cache-control": "no-store"
|
|
3184
|
-
}
|
|
3185
|
-
});
|
|
3428
|
+
await this.#wait_for_background();
|
|
3429
|
+
const token = this.#take("recording");
|
|
3430
|
+
if (token === null) return this.#busy("A profile is already running on this worker. Waiting for it to finish (or for a free worker).");
|
|
3186
3431
|
const q = ctx.url.searchParams;
|
|
3187
|
-
|
|
3188
|
-
const watchdog = setTimeout(() => {
|
|
3189
|
-
this.#recording_since = 0;
|
|
3190
|
-
this.#release_als();
|
|
3191
|
-
}, 32e3);
|
|
3192
|
-
watchdog.unref?.();
|
|
3432
|
+
const kept_owner = {};
|
|
3193
3433
|
this.#ensure_net?.();
|
|
3194
3434
|
try {
|
|
3195
3435
|
const path = replay?.path ?? q.get("p") ?? "";
|
|
@@ -3209,6 +3449,7 @@ var Profiler = class {
|
|
|
3209
3449
|
const request_deadline = Date.now() + detect_request_budget_ms();
|
|
3210
3450
|
const deadline = Math.min(budget_deadline, Date.now() + RECORDING_HARD_CAP_MS);
|
|
3211
3451
|
const fetch_render = (p, extra) => {
|
|
3452
|
+
if (!this.#holds(token)) return Promise.reject(/* @__PURE__ */ new Error("the recording was reset before it finished"));
|
|
3212
3453
|
const left = deadline - Date.now();
|
|
3213
3454
|
const ms = Number.isFinite(left) ? Math.max(1, Math.min(PER_RENDER_TIMEOUT_MS, left)) : PER_RENDER_TIMEOUT_MS;
|
|
3214
3455
|
return with_timeout(event.fetch(p, { headers: {
|
|
@@ -3302,7 +3543,7 @@ var Profiler = class {
|
|
|
3302
3543
|
skipped: heap_skipped
|
|
3303
3544
|
} : void 0;
|
|
3304
3545
|
};
|
|
3305
|
-
this.#kept?.keep(true);
|
|
3546
|
+
this.#kept?.keep(kept_owner, true);
|
|
3306
3547
|
mem_mark("before the timed renders");
|
|
3307
3548
|
const cap = await this.#capture_window(interval, async () => {
|
|
3308
3549
|
const path_only = target.split("?")[0];
|
|
@@ -3373,7 +3614,7 @@ var Profiler = class {
|
|
|
3373
3614
|
cap.set_aside = aside;
|
|
3374
3615
|
}
|
|
3375
3616
|
const stable = stable_answers(cap.net, run_windows);
|
|
3376
|
-
this.#kept?.keep(false);
|
|
3617
|
+
this.#kept?.keep(kept_owner, false);
|
|
3377
3618
|
const almost = /* @__PURE__ */ new Map();
|
|
3378
3619
|
if (this.#kept) for (const key of varied_answers(cap.net, run_windows)) {
|
|
3379
3620
|
const d = this.#kept.diff(key);
|
|
@@ -3404,8 +3645,8 @@ var Profiler = class {
|
|
|
3404
3645
|
} catch {
|
|
3405
3646
|
answers = void 0;
|
|
3406
3647
|
} finally {
|
|
3407
|
-
this.#answers = null;
|
|
3408
|
-
this.#kept.drop();
|
|
3648
|
+
if (this.#answers === state) this.#answers = null;
|
|
3649
|
+
this.#kept.drop(kept_owner);
|
|
3409
3650
|
}
|
|
3410
3651
|
}
|
|
3411
3652
|
const wait_runs = {};
|
|
@@ -3490,12 +3731,18 @@ var Profiler = class {
|
|
|
3490
3731
|
}, true) : (heap_skipped.push("the call-count render (the ×N counts)"), { counts: {} });
|
|
3491
3732
|
cap.call_counts = counted.counts;
|
|
3492
3733
|
mem_mark("after the coverage render");
|
|
3493
|
-
const app_fetch = async (u) => {
|
|
3734
|
+
const app_fetch = async (u, signal) => {
|
|
3494
3735
|
try {
|
|
3495
|
-
const r = await event.fetch(u, {
|
|
3736
|
+
const r = await event.fetch(u, {
|
|
3737
|
+
headers: { "x-og-profiler-internal": "1" },
|
|
3738
|
+
signal
|
|
3739
|
+
});
|
|
3496
3740
|
if (r.ok) return r;
|
|
3497
3741
|
} catch {}
|
|
3498
|
-
return fetch(new URL(u, event.url.origin), {
|
|
3742
|
+
return fetch(new URL(u, event.url.origin), {
|
|
3743
|
+
headers: { "x-og-profiler-internal": "1" },
|
|
3744
|
+
signal
|
|
3745
|
+
});
|
|
3499
3746
|
};
|
|
3500
3747
|
const id = await this.#finish_report(cap, {
|
|
3501
3748
|
trigger: "page",
|
|
@@ -3604,7 +3851,8 @@ var Profiler = class {
|
|
|
3604
3851
|
await this.#lines_for(s, counted.profile, cap.window?.start, stable, wait_runs, almost);
|
|
3605
3852
|
} catch {}
|
|
3606
3853
|
const persist_wait = Math.max(200, Math.min(PERSIST_WAIT_MS, request_deadline - Date.now() - 2e3));
|
|
3607
|
-
|
|
3854
|
+
const bound = scope(current_request_scope() ?? void 0);
|
|
3855
|
+
await Promise.race([this.#persist(s), pause(bound, persist_wait)]).finally(() => bound.close());
|
|
3608
3856
|
if (q.get("format") === "keep") return ctx.json({
|
|
3609
3857
|
id,
|
|
3610
3858
|
url: ctx.href("/report/[id]", { id }),
|
|
@@ -3626,13 +3874,8 @@ var Profiler = class {
|
|
|
3626
3874
|
if (isHttpError(e) || isRedirect(e)) throw e;
|
|
3627
3875
|
return error(500, e instanceof Error && INSPECTOR_ERR.test(e.message) ? "CPU profiling needs a Node.js server (adapter-node or dev). This platform does not expose the V8 inspector." : `Profiling failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
3628
3876
|
} finally {
|
|
3629
|
-
|
|
3630
|
-
this.#
|
|
3631
|
-
this.#kept?.keep(false);
|
|
3632
|
-
this.#kept?.drop();
|
|
3633
|
-
this.#answers = null;
|
|
3634
|
-
this.#release_als();
|
|
3635
|
-
this.#release_recorder();
|
|
3877
|
+
this.#kept?.drop(kept_owner);
|
|
3878
|
+
this.#free(token);
|
|
3636
3879
|
}
|
|
3637
3880
|
}
|
|
3638
3881
|
async #upload(ctx) {
|
|
@@ -3931,6 +4174,18 @@ var Profiler = class {
|
|
|
3931
4174
|
* fails the recording). The dump is the same portable JSON the `.ogp` export uses. The returned
|
|
3932
4175
|
* promise settles when the write did: a serverless host freezes the process once the response
|
|
3933
4176
|
* is sent, so a write left running after it may never land — the page recording awaits it. */
|
|
4177
|
+
/** The durable store, once its `init` has succeeded; null when none is configured or its init
|
|
4178
|
+
* failed this time (said, and tried again by the next use). */
|
|
4179
|
+
async #store_ready() {
|
|
4180
|
+
const store = this.#store;
|
|
4181
|
+
if (!store || !this.#store_init) return store;
|
|
4182
|
+
try {
|
|
4183
|
+
return await this.#store_init.get() === STALE ? null : store;
|
|
4184
|
+
} catch (e) {
|
|
4185
|
+
console.error(new OgygiaError("devtools_profiler_store_init_failed"), e);
|
|
4186
|
+
return null;
|
|
4187
|
+
}
|
|
4188
|
+
}
|
|
3934
4189
|
#persist(stored) {
|
|
3935
4190
|
if (!this.#store) return Promise.resolve();
|
|
3936
4191
|
const meta = stored.meta;
|
|
@@ -3945,10 +4200,12 @@ var Profiler = class {
|
|
|
3945
4200
|
dump: JSON.stringify(report_dump(stored.analysis, meta, this.#report_extras(stored)))
|
|
3946
4201
|
};
|
|
3947
4202
|
return Promise.resolve().then(async () => {
|
|
3948
|
-
await this.#
|
|
3949
|
-
|
|
4203
|
+
const store = await this.#store_ready();
|
|
4204
|
+
if (!store) return;
|
|
4205
|
+
await store.putReport(rec);
|
|
4206
|
+
await store.prune?.(Math.max(this.max_reports * 8, 200));
|
|
3950
4207
|
}).catch((err) => {
|
|
3951
|
-
if (process.env.OGYGIA_PROFILER_DEBUG) console.error(
|
|
4208
|
+
if (process.env.OGYGIA_PROFILER_DEBUG) console.error(new OgygiaError("devtools_profiler_put_report_failed"), err);
|
|
3952
4209
|
});
|
|
3953
4210
|
}
|
|
3954
4211
|
/** Memory first, then the durable store: read the dump back, reconstruct, cache in memory. */
|
|
@@ -3956,9 +4213,10 @@ var Profiler = class {
|
|
|
3956
4213
|
if (!id) return void 0;
|
|
3957
4214
|
const hit = this.#reports.get(id);
|
|
3958
4215
|
if (hit) return hit;
|
|
3959
|
-
|
|
4216
|
+
const store = await this.#store_ready();
|
|
4217
|
+
if (!store) return void 0;
|
|
3960
4218
|
try {
|
|
3961
|
-
const json = await
|
|
4219
|
+
const json = await store.getReport(id);
|
|
3962
4220
|
if (!json) return void 0;
|
|
3963
4221
|
const dump = JSON.parse(json);
|
|
3964
4222
|
if (!dump?.meta || !dump.analysis) return void 0;
|
|
@@ -3972,15 +4230,16 @@ var Profiler = class {
|
|
|
3972
4230
|
this.#reports.set(id, stored);
|
|
3973
4231
|
return stored;
|
|
3974
4232
|
} catch (err) {
|
|
3975
|
-
if (process.env.OGYGIA_PROFILER_DEBUG) console.error(
|
|
4233
|
+
if (process.env.OGYGIA_PROFILER_DEBUG) console.error(new OgygiaError("devtools_profiler_get_report_failed"), err);
|
|
3976
4234
|
return;
|
|
3977
4235
|
}
|
|
3978
4236
|
}
|
|
3979
4237
|
/** The reports the durable store holds (summaries), newest first — the sidebar's shared list. */
|
|
3980
4238
|
async #list_stored(limit = 20) {
|
|
3981
|
-
|
|
4239
|
+
const store = await this.#store_ready();
|
|
4240
|
+
if (!store) return [];
|
|
3982
4241
|
try {
|
|
3983
|
-
return (await
|
|
4242
|
+
return (await store.listReports(limit)).map((r) => ({
|
|
3984
4243
|
id: r.id,
|
|
3985
4244
|
label: r.label,
|
|
3986
4245
|
page: r.page ?? void 0,
|
|
@@ -4501,8 +4760,9 @@ var Profiler = class {
|
|
|
4501
4760
|
if (Object.keys(kept).length) entry.replay_headers = kept;
|
|
4502
4761
|
}
|
|
4503
4762
|
const profile_header = event.request.headers.get("x-profile");
|
|
4504
|
-
const
|
|
4505
|
-
const
|
|
4763
|
+
const header_token = !!profile_header && this.ui_enabled && await this.#key_matches(profile_header) ? this.#take("recording") : null;
|
|
4764
|
+
const header_profile = header_token !== null;
|
|
4765
|
+
const ctx = !!this.#als && (this.dev || this.#recording_active() || header_profile) ? this.#request_ctx(entry, request_scope(event.request)) : null;
|
|
4506
4766
|
const answers = this.#answers;
|
|
4507
4767
|
if (ctx && answers && this.#kept && event.request.headers.get(ANSWERS_HEADER) === answers.token) {
|
|
4508
4768
|
const kept = this.#kept;
|
|
@@ -4570,47 +4830,36 @@ var Profiler = class {
|
|
|
4570
4830
|
} catch {}
|
|
4571
4831
|
return res;
|
|
4572
4832
|
};
|
|
4573
|
-
if (
|
|
4574
|
-
|
|
4575
|
-
const
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4833
|
+
if (header_token !== null) try {
|
|
4834
|
+
let res;
|
|
4835
|
+
const cap = await this.#capture_window(100, async () => {
|
|
4836
|
+
res = await run();
|
|
4837
|
+
});
|
|
4838
|
+
this.#finalize(entry, start, cpu0, ctx, event.request);
|
|
4839
|
+
cap.window = {
|
|
4840
|
+
start,
|
|
4841
|
+
end: start + entry.ms
|
|
4842
|
+
};
|
|
4843
|
+
const id = await this.#finish_report(cap, {
|
|
4844
|
+
trigger: "request",
|
|
4845
|
+
request: {
|
|
4846
|
+
method: entry.method,
|
|
4847
|
+
path: entry.path,
|
|
4848
|
+
route: entry.route,
|
|
4849
|
+
ms: entry.ms
|
|
4850
|
+
}
|
|
4851
|
+
});
|
|
4852
|
+
const stored = this.#reports.get(id);
|
|
4853
|
+
if (stored) stored.replay = {
|
|
4854
|
+
path: entry.path + (entry.search ?? ""),
|
|
4855
|
+
headers: entry.replay_headers ?? {}
|
|
4856
|
+
};
|
|
4580
4857
|
try {
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
cap.window = {
|
|
4587
|
-
start,
|
|
4588
|
-
end: start + entry.ms
|
|
4589
|
-
};
|
|
4590
|
-
const id = await this.#finish_report(cap, {
|
|
4591
|
-
trigger: "request",
|
|
4592
|
-
request: {
|
|
4593
|
-
method: entry.method,
|
|
4594
|
-
path: entry.path,
|
|
4595
|
-
route: entry.route,
|
|
4596
|
-
ms: entry.ms
|
|
4597
|
-
}
|
|
4598
|
-
});
|
|
4599
|
-
const stored = this.#reports.get(id);
|
|
4600
|
-
if (stored) stored.replay = {
|
|
4601
|
-
path: entry.path + (entry.search ?? ""),
|
|
4602
|
-
headers: entry.replay_headers ?? {}
|
|
4603
|
-
};
|
|
4604
|
-
try {
|
|
4605
|
-
res?.headers.append("x-profile-report", `${this.base}/report/${id}`);
|
|
4606
|
-
} catch {}
|
|
4607
|
-
return res;
|
|
4608
|
-
} finally {
|
|
4609
|
-
clearTimeout(watchdog);
|
|
4610
|
-
this.#recording_since = 0;
|
|
4611
|
-
this.#release_als();
|
|
4612
|
-
this.#release_recorder();
|
|
4613
|
-
}
|
|
4858
|
+
res?.headers.append("x-profile-report", `${this.base}/report/${id}`);
|
|
4859
|
+
} catch {}
|
|
4860
|
+
return res;
|
|
4861
|
+
} finally {
|
|
4862
|
+
this.#free(header_token);
|
|
4614
4863
|
}
|
|
4615
4864
|
try {
|
|
4616
4865
|
return await run();
|
|
@@ -4661,47 +4910,72 @@ var Profiler = class {
|
|
|
4661
4910
|
if (Number.isFinite(serverless_work_budget_ms())) this.#sink_flush();
|
|
4662
4911
|
}
|
|
4663
4912
|
}
|
|
4664
|
-
/**
|
|
4665
|
-
*
|
|
4666
|
-
|
|
4913
|
+
/** Hand the buffered rows to the sink's outbox as one post, now. While a post is out, the rows
|
|
4914
|
+
* wait in the buffer (capped: it drops the oldest when full) and go in one post once it ends,
|
|
4915
|
+
* never a post per tick queued behind a sink that is down (#376). Settles once the rows asked
|
|
4916
|
+
* for were taken or given up; never rejects. */
|
|
4917
|
+
#sink_flush() {
|
|
4667
4918
|
if (this.#sink_timer && this.#superseded()) {
|
|
4668
|
-
|
|
4919
|
+
this.#sink_timer();
|
|
4669
4920
|
this.#sink_timer = null;
|
|
4670
4921
|
}
|
|
4671
|
-
|
|
4672
|
-
|
|
4922
|
+
const out = this.#sink_out;
|
|
4923
|
+
if (out !== null) {
|
|
4924
|
+
out.again = true;
|
|
4925
|
+
return out.done;
|
|
4926
|
+
}
|
|
4927
|
+
if (!this.#sink_url || !this.#sink.size) return Promise.resolve();
|
|
4673
4928
|
const rows = this.#sink.size;
|
|
4674
|
-
const
|
|
4929
|
+
const post = {
|
|
4930
|
+
id: `${this.#sink_run}-${++this.#sink_count}`,
|
|
4931
|
+
body: this.#sink.drain(),
|
|
4932
|
+
rows
|
|
4933
|
+
};
|
|
4934
|
+
const posting = {
|
|
4935
|
+
again: false,
|
|
4936
|
+
done: Promise.resolve()
|
|
4937
|
+
};
|
|
4938
|
+
this.#sink_out = posting;
|
|
4939
|
+
posting.done = this.#sink_posts.put(post).then(() => {
|
|
4940
|
+
this.#sink_out = null;
|
|
4941
|
+
if (posting.again) return this.#sink_flush();
|
|
4942
|
+
});
|
|
4943
|
+
return posting.done;
|
|
4944
|
+
}
|
|
4945
|
+
/** The post out now, and whether a flush asked for the rows read meanwhile. */
|
|
4946
|
+
#sink_out = null;
|
|
4947
|
+
/** Each post's id: this profiler's run, and its count. */
|
|
4948
|
+
#sink_run = Math.random().toString(36).slice(2, 10);
|
|
4949
|
+
#sink_count = 0;
|
|
4950
|
+
/** One try at a post: done once the sink answered ok. Its id rides `idempotency-key`, so a sink
|
|
4951
|
+
* that saw it already (an answer lost on the way back) can drop the copy. */
|
|
4952
|
+
async #sink_post(post, signal) {
|
|
4675
4953
|
try {
|
|
4676
4954
|
const res = await fetch(this.#sink_url, {
|
|
4677
4955
|
method: "POST",
|
|
4678
|
-
body,
|
|
4956
|
+
body: post.body,
|
|
4679
4957
|
headers: {
|
|
4680
4958
|
"content-type": "application/x-ndjson",
|
|
4959
|
+
"idempotency-key": post.id,
|
|
4681
4960
|
"x-og-profiler-internal": "1",
|
|
4682
4961
|
...this.#sink_key ? { authorization: `Bearer ${this.#sink_key}` } : {}
|
|
4683
4962
|
},
|
|
4684
|
-
signal: AbortSignal.timeout(
|
|
4963
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(SINK_TRY_MS)])
|
|
4685
4964
|
});
|
|
4965
|
+
if (!res.ok) throw new Error(`the sink answered ${res.status}`);
|
|
4686
4966
|
this.#sink_last = {
|
|
4687
4967
|
at: Date.now(),
|
|
4688
|
-
ok:
|
|
4689
|
-
rows
|
|
4690
|
-
...res.ok ? {} : { error: `the sink answered ${res.status}` }
|
|
4968
|
+
ok: true,
|
|
4969
|
+
rows: post.rows
|
|
4691
4970
|
};
|
|
4692
|
-
|
|
4693
|
-
for (const line of body.split("\n")) if (line) this.#sink.push(JSON.parse(line));
|
|
4694
|
-
}
|
|
4695
|
-
} catch (e) {
|
|
4971
|
+
} catch (error) {
|
|
4696
4972
|
this.#sink_last = {
|
|
4697
4973
|
at: Date.now(),
|
|
4698
4974
|
ok: false,
|
|
4699
|
-
rows,
|
|
4700
|
-
error:
|
|
4975
|
+
rows: post.rows,
|
|
4976
|
+
error: error_text(error)
|
|
4701
4977
|
};
|
|
4702
|
-
|
|
4703
|
-
} finally {
|
|
4704
|
-
this.#sink_inflight = false;
|
|
4978
|
+
throw error;
|
|
4705
4979
|
}
|
|
4706
4980
|
}
|
|
4707
4981
|
/** The site view's rows from THIS instance: the request ring and the sampler's windows — what a
|
|
@@ -4806,12 +5080,13 @@ function clamp(n, lo, hi) {
|
|
|
4806
5080
|
* is left to settle on its own — the caller has already moved on. */
|
|
4807
5081
|
function with_timeout(p, ms, label) {
|
|
4808
5082
|
if (!Number.isFinite(ms) || ms <= 0) return p;
|
|
4809
|
-
|
|
4810
|
-
const timeout = new Promise((_, reject) =>
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
|
|
5083
|
+
const race = scope();
|
|
5084
|
+
const timeout = new Promise((_, reject) => race.timeout(() => reject(new Error(label)), Math.min(ms, 2147483647)));
|
|
5085
|
+
return Promise.race([p.finally(() => race.close()), timeout]);
|
|
5086
|
+
}
|
|
5087
|
+
/** Settles after `ms`, or at once when `owner` ends (its timer goes with it). */
|
|
5088
|
+
function pause(owner, ms) {
|
|
5089
|
+
return owner.task(() => new Promise((r) => owner.timeout(r, ms)));
|
|
4815
5090
|
}
|
|
4816
5091
|
/** OGYGIA_PROFILER_DEBUG: the process's memory at a step of a recording (what the profiler itself
|
|
4817
5092
|
* holds on a small serverless instance) */
|
|
@@ -4918,4 +5193,4 @@ function imported_from(src, name, file) {
|
|
|
4918
5193
|
return;
|
|
4919
5194
|
}
|
|
4920
5195
|
}
|
|
4921
|
-
export { pick_fields, profiler, route_prerendered, self_profile_to_cpuprofile };
|
|
5196
|
+
export { pick_fields, profiler, route_prerendered, self_profile_to_cpuprofile, url_weights, weigh_urls };
|