@ogygia/devtools 0.12.0 → 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/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/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 +13 -4
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { define_messages, dev_or_prerender, prose } from "@ogygia/core/internal/messages";
|
|
2
|
+
// src/messages/runtime.ts
|
|
3
|
+
/**
|
|
4
|
+
* EVERY MESSAGE THE PROFILER'S SERVER HALF AND THE DOCK PRINT, in one place: its code, its wording,
|
|
5
|
+
* what to do about it (core's `@ogygia/core/internal/messages` says how the system works).
|
|
6
|
+
*
|
|
7
|
+
* console.error(new OgygiaError('devtools_profiler_store_init_failed'), e);
|
|
8
|
+
* warn('devtools_profiler_session_rejected', { path, diagnosis, reason });
|
|
9
|
+
*
|
|
10
|
+
* It runs in the app's server build (and the dock's launcher in its browser bundle), so the wording is
|
|
11
|
+
* only passed in dev: a production build prints the code's page alone. The Vite plugin's messages are
|
|
12
|
+
* in ./build.ts.
|
|
13
|
+
*/
|
|
14
|
+
/** The errors: `throw new OgygiaError(code, args)`, or `console.error(new OgygiaError(…), err)` for a
|
|
15
|
+
* failure that is caught. */
|
|
16
|
+
const DEVTOOLS_ERRORS = {
|
|
17
|
+
devtools_profiler_store_init_failed: {
|
|
18
|
+
text: () => "store init failed; falling back to in-memory:",
|
|
19
|
+
details: () => prose`
|
|
20
|
+
The profiler's \`store\` threw while it initialized (a missing driver, a bad path, a
|
|
21
|
+
database it cannot reach; the error follows the message). The profiler keeps its reports
|
|
22
|
+
in memory instead, so they are gone when the server restarts.
|
|
23
|
+
`
|
|
24
|
+
},
|
|
25
|
+
devtools_profiler_mount_failed: {
|
|
26
|
+
text: () => "the profiler could not start; requests are served without it:",
|
|
27
|
+
details: () => prose`
|
|
28
|
+
The profiler (\`devtools({ profiler })\`) loads on the first request, and loading it
|
|
29
|
+
failed (the error follows the message): a runtime without \`node:inspector\` (an edge
|
|
30
|
+
runtime), or a dev server in the middle of an edit. Every request is still answered,
|
|
31
|
+
unprofiled, and the next request tries to start it again. Said once per server.
|
|
32
|
+
`
|
|
33
|
+
},
|
|
34
|
+
devtools_profiler_alloc_sampling_unavailable: {
|
|
35
|
+
text: () => "allocation sampling unavailable, GC attribution off:",
|
|
36
|
+
details: () => prose`
|
|
37
|
+
Printed with \`OGYGIA_PROFILER_DEBUG\` set. The runtime refused the heap sampling the
|
|
38
|
+
profiler asked for (the error follows the message), so this recording does not say which
|
|
39
|
+
code caused its garbage collection.
|
|
40
|
+
`
|
|
41
|
+
},
|
|
42
|
+
devtools_profiler_gc_attribution_failed: {
|
|
43
|
+
text: () => "GC attribution failed:",
|
|
44
|
+
details: () => prose`
|
|
45
|
+
Printed with \`OGYGIA_PROFILER_DEBUG\` set. Working out which code caused the recording's
|
|
46
|
+
garbage collection threw (the error follows the message); the report leaves that part
|
|
47
|
+
out.
|
|
48
|
+
`
|
|
49
|
+
},
|
|
50
|
+
devtools_profiler_put_report_failed: {
|
|
51
|
+
text: () => "store.putReport failed:",
|
|
52
|
+
details: () => prose`
|
|
53
|
+
Printed with \`OGYGIA_PROFILER_DEBUG\` set. The profiler's \`store\` failed to save a
|
|
54
|
+
report (the error follows the message). The report stays in memory, but is gone when the
|
|
55
|
+
server restarts.
|
|
56
|
+
`
|
|
57
|
+
},
|
|
58
|
+
devtools_profiler_get_report_failed: {
|
|
59
|
+
text: () => "store.getReport failed:",
|
|
60
|
+
details: () => prose`
|
|
61
|
+
Printed with \`OGYGIA_PROFILER_DEBUG\` set. The profiler's \`store\` failed to read a
|
|
62
|
+
report back (the error follows the message), so the report is answered as not found.
|
|
63
|
+
`
|
|
64
|
+
},
|
|
65
|
+
devtools_dock_load_failed: {
|
|
66
|
+
text: () => "the devtools dock’s code failed to load (click its button to try again):",
|
|
67
|
+
details: () => prose`
|
|
68
|
+
The browser could not download the devtools dock's code (the network failed, or a deploy
|
|
69
|
+
removed the file; the error follows the message). The page is not affected, and the
|
|
70
|
+
dock's button stays: a click, or Alt+O, asks for the code again under a fresh URL.
|
|
71
|
+
`
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
/** The warnings: `warn(code, args, ...rest)`. */
|
|
75
|
+
const DEVTOOLS_WARNINGS = {
|
|
76
|
+
devtools_profiler_session_rejected: {
|
|
77
|
+
text: (a) => `session not accepted on ${a.path}: ${a.diagnosis} — ${a.reason}`,
|
|
78
|
+
details: () => prose`
|
|
79
|
+
A browser sent a profiler session cookie, or had just logged in, and the profiler still
|
|
80
|
+
did not accept it. \`diagnosis\` counts what the request carried (never a cookie's value)
|
|
81
|
+
and \`reason\` reads it: no cookie reached the server (a proxy or CDN in front of the app
|
|
82
|
+
drops it), the browser sent several session cookies, or the session is not valid (the
|
|
83
|
+
profiler secret differs between server instances).
|
|
84
|
+
|
|
85
|
+
Forward cookies to the profiler's path, clear the site's cookies and log in again, or
|
|
86
|
+
give every instance the same secret.
|
|
87
|
+
`
|
|
88
|
+
},
|
|
89
|
+
devtools_profiler_sink_undelivered: {
|
|
90
|
+
text: (a) => `the sink never took a post of ${a.rows} rows, after every try; they are dropped:`,
|
|
91
|
+
details: () => prose`
|
|
92
|
+
The profiler posts the rows it reads (\`sink: { url }\`) one post at a time, in order, and
|
|
93
|
+
tries a failed post again a few times before the next one goes. This post failed every
|
|
94
|
+
time (the last error follows the message), so its rows are not at the sink and the site
|
|
95
|
+
view misses those requests. The site page shows the sink's last answer.
|
|
96
|
+
|
|
97
|
+
Check the sink's URL, its key, and that it answers 2xx once it stored a post. A post
|
|
98
|
+
carries an \`idempotency-key\` header: a sink that stored a post already can answer ok
|
|
99
|
+
for its copy.
|
|
100
|
+
`
|
|
101
|
+
},
|
|
102
|
+
devtools_profiler_background_stopped: {
|
|
103
|
+
text: (a) => `the profiler's ${a.loop} stopped: a background window failed:`,
|
|
104
|
+
details: () => prose`
|
|
105
|
+
The trap (\`trap\`) and the always-on sampler (\`sample\`) record short windows in the
|
|
106
|
+
background with the process's inspector. One of those windows failed (the error follows
|
|
107
|
+
the message): most often the inspector is held by another debugger, or the host has none.
|
|
108
|
+
That loop records no more windows in this process; the dashboard says so under "In the
|
|
109
|
+
background", with what the trap caught before it stopped.
|
|
110
|
+
|
|
111
|
+
Detach the other debugger and restart the server, or turn \`trap\` / \`sample\` off on this
|
|
112
|
+
host.
|
|
113
|
+
`
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const own = /* @__PURE__ */ define_messages(import.meta.env?.DEV || import.meta.env?.SSR ? DEVTOOLS_ERRORS : null, import.meta.env?.DEV || import.meta.env?.SSR ? DEVTOOLS_WARNINGS : null, dev_or_prerender);
|
|
117
|
+
/** `throw new OgygiaError(code, args)`; `warn(code, args, ...rest)` logs; `line(code, args)` is the
|
|
118
|
+
* line as a string. Wording and link in dev and while prerendering, the link alone otherwise. */
|
|
119
|
+
const OgygiaError = own.OgygiaError;
|
|
120
|
+
const warn = own.warn;
|
|
121
|
+
const line = own.line;
|
|
122
|
+
export { DEVTOOLS_ERRORS, DEVTOOLS_WARNINGS, OgygiaError, line, warn };
|
|
@@ -26,8 +26,17 @@ export interface IoOp {
|
|
|
26
26
|
* across the runs, before the io is scoped to one) — a timer each render starts and never ends */
|
|
27
27
|
left_each_run?: true;
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
/** `fn (dir/file:line)` from a generated position: what a promise origin reads as when no
|
|
30
|
+
* sourcemap maps it */
|
|
31
|
+
export declare function short_site(s: CallerSite): string;
|
|
32
|
+
/** Times I/O resources over one recording window: `IoRecorder.start()` at its start, `stop()` at
|
|
33
|
+
* its end. */
|
|
34
|
+
export declare class IoRecorder {
|
|
35
|
+
#private;
|
|
36
|
+
/** Start timing I/O resources; null without `async_hooks` (edge) — the fetch patch and wall/CPU
|
|
37
|
+
* split still work there. */
|
|
38
|
+
static start(): Promise<IoRecorder | null>;
|
|
39
|
+
constructor(async_hooks: typeof import("node:async_hooks"));
|
|
31
40
|
/** promises created while the recorder ran, and who created them (one stack per
|
|
32
41
|
* PROMISE_SAMPLE_EVERY promises — a counter otherwise) */
|
|
33
42
|
promises(): {
|
|
@@ -38,12 +47,8 @@ export interface IoRecorder {
|
|
|
38
47
|
site?: CallerSite;
|
|
39
48
|
}[];
|
|
40
49
|
};
|
|
50
|
+
stop(): IoOp[];
|
|
41
51
|
}
|
|
42
|
-
/** `fn (dir/file:line)` from a generated position: what a promise origin reads as when no
|
|
43
|
-
* sourcemap maps it */
|
|
44
|
-
export declare function short_site(s: CallerSite): string;
|
|
45
|
-
/** Start timing I/O resources. Call `stop()` at the end of the window. */
|
|
46
|
-
export declare function record_async_io(): Promise<IoRecorder | null>;
|
|
47
52
|
/**
|
|
48
53
|
* A timer a render starts and never ends: an interval (or a timeout of a second or more) still open
|
|
49
54
|
* after every render, started from the same line in each of them. One per render is the proof it is
|
|
@@ -61,91 +61,103 @@ function short_site(s) {
|
|
|
61
61
|
const clean = q === -1 ? s.file : s.file.slice(0, q);
|
|
62
62
|
return `${s.fn} (${clean.split("/").slice(-2).join("/")}:${s.line})`;
|
|
63
63
|
}
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
const open = /* @__PURE__ */ new Map();
|
|
73
|
-
const ops = [];
|
|
74
|
-
let promise_count = 0;
|
|
64
|
+
/** Times I/O resources over one recording window: `IoRecorder.start()` at its start, `stop()` at
|
|
65
|
+
* its end. */
|
|
66
|
+
var IoRecorder = class IoRecorder {
|
|
67
|
+
#open = /* @__PURE__ */ new Map();
|
|
68
|
+
#ops = [];
|
|
69
|
+
#promise_count = 0;
|
|
75
70
|
/** generated `fn\0file\0line` → how many sampled promises it made, and the site itself */
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
71
|
+
#promise_sites = /* @__PURE__ */ new Map();
|
|
72
|
+
#hook;
|
|
73
|
+
/** Start timing I/O resources; null without `async_hooks` (edge) — the fetch patch and wall/CPU
|
|
74
|
+
* split still work there. */
|
|
75
|
+
static async start() {
|
|
76
|
+
let async_hooks;
|
|
77
|
+
try {
|
|
78
|
+
async_hooks = await import("node:async_hooks");
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return new IoRecorder(async_hooks);
|
|
83
|
+
}
|
|
84
|
+
constructor(async_hooks) {
|
|
85
|
+
this.#hook = async_hooks.createHook({
|
|
86
|
+
init: (asyncId, type, _trigger, resource) => this.#init(asyncId, type, resource),
|
|
87
|
+
destroy: (asyncId) => this.#destroy(asyncId)
|
|
88
|
+
});
|
|
89
|
+
this.#hook.enable();
|
|
90
|
+
}
|
|
91
|
+
/** promises created while the recorder ran, and who created them (one stack per
|
|
92
|
+
* PROMISE_SAMPLE_EVERY promises — a counter otherwise) */
|
|
93
|
+
promises() {
|
|
94
|
+
const sampled = [...this.#promise_sites.values()].reduce((a, b) => a + b.n, 0) || 1;
|
|
95
|
+
return {
|
|
96
|
+
count: this.#promise_count,
|
|
97
|
+
top: [...this.#promise_sites.values()].sort((a, b) => b.n - a.n).slice(0, 8).map(({ n, site }) => ({
|
|
98
|
+
caller: site ? short_site(site) : "(no frame outside node)",
|
|
99
|
+
share: Math.round(n / sampled * 100) / 100,
|
|
100
|
+
...site ? { site } : {}
|
|
101
|
+
}))
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
stop() {
|
|
105
|
+
this.#hook.disable();
|
|
106
|
+
const ops = this.#ops;
|
|
107
|
+
const now = performance.now();
|
|
108
|
+
for (const s of this.#open.values()) if (ops.length < MAX) ops.push({
|
|
109
|
+
type: s.type,
|
|
110
|
+
caller_site: s.site,
|
|
111
|
+
ms: round2(now - s.t),
|
|
112
|
+
start: s.t,
|
|
113
|
+
open: true,
|
|
114
|
+
...s.repeat !== void 0 ? { repeat: s.repeat } : {},
|
|
115
|
+
...s.delay !== void 0 ? { delay: s.delay } : {},
|
|
116
|
+
...s.timer?.hasRef?.() === false ? { unref: true } : {}
|
|
117
|
+
});
|
|
118
|
+
this.#open.clear();
|
|
119
|
+
return ops;
|
|
120
|
+
}
|
|
121
|
+
#init(asyncId, type, resource) {
|
|
122
|
+
if (type === "PROMISE") {
|
|
123
|
+
if (++this.#promise_count % PROMISE_SAMPLE_EVERY === 0 && this.#promise_sites.size < 200) {
|
|
124
|
+
const site = nearest_own_or_dep_site() ?? null;
|
|
125
|
+
const k = site ? site.fn + "\0" + site.file + "\0" + site.line : "";
|
|
126
|
+
const hit = this.#promise_sites.get(k);
|
|
127
|
+
if (hit) hit.n++;
|
|
128
|
+
else this.#promise_sites.set(k, {
|
|
129
|
+
n: 1,
|
|
130
|
+
site
|
|
131
|
+
});
|
|
103
132
|
}
|
|
104
|
-
|
|
105
|
-
},
|
|
106
|
-
destroy(asyncId) {
|
|
107
|
-
const s = open.get(asyncId);
|
|
108
|
-
if (!s) return;
|
|
109
|
-
open.delete(asyncId);
|
|
110
|
-
if (ops.length < MAX) ops.push({
|
|
111
|
-
type: s.type,
|
|
112
|
-
caller_site: s.site,
|
|
113
|
-
ms: round2(performance.now() - s.t),
|
|
114
|
-
start: s.t
|
|
115
|
-
});
|
|
133
|
+
return;
|
|
116
134
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}))
|
|
129
|
-
};
|
|
130
|
-
},
|
|
131
|
-
stop() {
|
|
132
|
-
hook.disable();
|
|
133
|
-
const now = performance.now();
|
|
134
|
-
for (const s of open.values()) if (ops.length < MAX) ops.push({
|
|
135
|
-
type: s.type,
|
|
136
|
-
caller_site: s.site,
|
|
137
|
-
ms: round2(now - s.t),
|
|
138
|
-
start: s.t,
|
|
139
|
-
open: true,
|
|
140
|
-
...s.repeat !== void 0 ? { repeat: s.repeat } : {},
|
|
141
|
-
...s.delay !== void 0 ? { delay: s.delay } : {},
|
|
142
|
-
...s.timer?.hasRef?.() === false ? { unref: true } : {}
|
|
143
|
-
});
|
|
144
|
-
open.clear();
|
|
145
|
-
return ops;
|
|
135
|
+
if (this.#open.size >= MAX || !TRACK.has(type)) return;
|
|
136
|
+
const s = {
|
|
137
|
+
t: performance.now(),
|
|
138
|
+
type,
|
|
139
|
+
site: nearest_app_site()
|
|
140
|
+
};
|
|
141
|
+
if (type === "Timeout") {
|
|
142
|
+
const r = resource;
|
|
143
|
+
if (typeof r._repeat === "number" && r._repeat > 0) s.repeat = r._repeat;
|
|
144
|
+
else if (typeof r._idleTimeout === "number" && r._idleTimeout >= 0) s.delay = r._idleTimeout;
|
|
145
|
+
s.timer = r;
|
|
146
146
|
}
|
|
147
|
-
|
|
148
|
-
}
|
|
147
|
+
this.#open.set(asyncId, s);
|
|
148
|
+
}
|
|
149
|
+
#destroy(asyncId) {
|
|
150
|
+
const s = this.#open.get(asyncId);
|
|
151
|
+
if (!s) return;
|
|
152
|
+
this.#open.delete(asyncId);
|
|
153
|
+
if (this.#ops.length < MAX) this.#ops.push({
|
|
154
|
+
type: s.type,
|
|
155
|
+
caller_site: s.site,
|
|
156
|
+
ms: round2(performance.now() - s.t),
|
|
157
|
+
start: s.t
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
};
|
|
149
161
|
/**
|
|
150
162
|
* A timer a render starts and never ends: an interval (or a timeout of a second or more) still open
|
|
151
163
|
* after every render, started from the same line in each of them. One per render is the proof it is
|
|
@@ -184,4 +196,4 @@ function io_kind(type) {
|
|
|
184
196
|
function round2(n) {
|
|
185
197
|
return Math.round(n * 100) / 100;
|
|
186
198
|
}
|
|
187
|
-
export { io_kind, mark_left_each_run,
|
|
199
|
+
export { IoRecorder, io_kind, mark_left_each_run, short_site };
|
|
@@ -34,7 +34,7 @@ function standalone_beacon() {
|
|
|
34
34
|
const seen = [];
|
|
35
35
|
let off = false;
|
|
36
36
|
let cls = 0;
|
|
37
|
-
let
|
|
37
|
+
let visit_state = "open";
|
|
38
38
|
let resend;
|
|
39
39
|
/** visits sent while the page lives (the early one, then re-sends): at most 8 */
|
|
40
40
|
let early_sends = 0;
|
|
@@ -147,7 +147,7 @@ function standalone_beacon() {
|
|
|
147
147
|
function send(body, slim) {
|
|
148
148
|
const room = 6e4 - queued;
|
|
149
149
|
if (body.length > room) {
|
|
150
|
-
if (
|
|
150
|
+
if (visit_state !== "sent" && document.visibilityState === "visible") return post(body, false);
|
|
151
151
|
const smaller = slim?.();
|
|
152
152
|
if (!smaller || smaller.length > room) return;
|
|
153
153
|
body = smaller;
|
|
@@ -333,8 +333,9 @@ function standalone_beacon() {
|
|
|
333
333
|
}
|
|
334
334
|
/** the page is going away: the vitals (final only now), the visit, the CPU trace */
|
|
335
335
|
function hide() {
|
|
336
|
-
if (runtime_claimed() || sent) return;
|
|
337
|
-
|
|
336
|
+
if (runtime_claimed() || visit_state === "sent") return;
|
|
337
|
+
visit_state = "sent";
|
|
338
|
+
clearTimeout(resend);
|
|
338
339
|
if (Object.keys(vitals).length) send(JSON.stringify({
|
|
339
340
|
page: location.pathname,
|
|
340
341
|
at: Math.round(performance.timeOrigin),
|
|
@@ -353,6 +354,10 @@ function standalone_beacon() {
|
|
|
353
354
|
}));
|
|
354
355
|
cpu(true);
|
|
355
356
|
}
|
|
357
|
+
/** the page is shown again (its tab, or a back/forward cache restore): the visit goes on */
|
|
358
|
+
function show() {
|
|
359
|
+
if (visit_state === "sent") visit_state = "reopened";
|
|
360
|
+
}
|
|
356
361
|
/** a later paint or shift: the visit again a moment later (debounced), once one has gone */
|
|
357
362
|
function again() {
|
|
358
363
|
if (!early_sends || early_sends >= 8) return;
|
|
@@ -371,7 +376,7 @@ function standalone_beacon() {
|
|
|
371
376
|
}
|
|
372
377
|
/** the visit while the page lives: the early one, and each re-send */
|
|
373
378
|
function early() {
|
|
374
|
-
if (runtime_claimed() || early_sends >= 8) return;
|
|
379
|
+
if (runtime_claimed() || visit_state === "sent" || early_sends >= 8) return;
|
|
375
380
|
early_sends++;
|
|
376
381
|
const v = visit();
|
|
377
382
|
if (v) send(JSON.stringify({
|
|
@@ -387,7 +392,7 @@ function standalone_beacon() {
|
|
|
387
392
|
} catch {}
|
|
388
393
|
if (profiler) {
|
|
389
394
|
try {
|
|
390
|
-
profiler.stop();
|
|
395
|
+
profiler.stop().catch(() => {});
|
|
391
396
|
} catch {}
|
|
392
397
|
profiler = null;
|
|
393
398
|
}
|
|
@@ -396,8 +401,12 @@ function standalone_beacon() {
|
|
|
396
401
|
if (document.querySelector("script[data-ogygia-runtime]")) return stand_down();
|
|
397
402
|
document.addEventListener("visibilitychange", () => {
|
|
398
403
|
if (document.visibilityState === "hidden") hide();
|
|
404
|
+
else show();
|
|
399
405
|
});
|
|
400
406
|
addEventListener("pagehide", hide);
|
|
407
|
+
addEventListener("pageshow", (e) => {
|
|
408
|
+
if (e.persisted) show();
|
|
409
|
+
});
|
|
401
410
|
addEventListener("load", () => setTimeout(early, 1500));
|
|
402
411
|
});
|
|
403
412
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Scope, Stale } from "@ogygia/machine";
|
|
2
|
+
/** What a caller shows while it waits: each ask as it starts, each wait for a busy worker (with
|
|
3
|
+
* the time waited so far). */
|
|
4
|
+
export interface BusyWatch {
|
|
5
|
+
asking?: () => void;
|
|
6
|
+
busy?: (waited_ms: number) => void;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* `fetch(url)` until the answer is not a 409, or the wait runs out (the last 409 is the answer: the
|
|
10
|
+
* caller says so). `owner` owns the asks and the waits: once it ends, the request is aborted,
|
|
11
|
+
* nothing more is asked, and the answer is `STALE`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function fetch_when_free(owner: Scope, url: string, init?: RequestInit, { asking, busy }?: BusyWatch): Promise<Response | Stale>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { STALE, policy } from "@ogygia/machine";
|
|
2
|
+
// src/profiler/busy.ts
|
|
3
|
+
/**
|
|
4
|
+
* Waiting for a busy profiler. A worker records one profile at a time (the V8 profiler is
|
|
5
|
+
* process-wide): one that is recording answers `/page` with a 409. There is no queue on the server
|
|
6
|
+
* (a serverless worker is recycled within ~30 s), so the queue lives with the caller: ask again
|
|
7
|
+
* until a worker takes it. On serverless the next ask lands on a free worker; on one server it is
|
|
8
|
+
* taken once the profile ahead ends. Both the dock's tabs and the run page wait this one way.
|
|
9
|
+
*/
|
|
10
|
+
/** How a profile waits for a busy profiler: asked again every 2 s (or when the 409's
|
|
11
|
+
* `Retry-After` says), for up to 2 min; a profile ahead longer than that is a stuck one (Reset). */
|
|
12
|
+
const PROFILER_BUSY = policy({
|
|
13
|
+
attempts: 61,
|
|
14
|
+
backoff: 2e3,
|
|
15
|
+
jitter: 0,
|
|
16
|
+
deadline: 12e4
|
|
17
|
+
});
|
|
18
|
+
/** The wait before the next ask, after a 409: its `Retry-After` (seconds), else the policy's. */
|
|
19
|
+
function busy_wait(res) {
|
|
20
|
+
const after = Number(res.headers.get("retry-after")) * 1e3;
|
|
21
|
+
return Number.isFinite(after) && after > 0 ? after : PROFILER_BUSY.backoff;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* `fetch(url)` until the answer is not a 409, or the wait runs out (the last 409 is the answer: the
|
|
25
|
+
* caller says so). `owner` owns the asks and the waits: once it ends, the request is aborted,
|
|
26
|
+
* nothing more is asked, and the answer is `STALE`.
|
|
27
|
+
*/
|
|
28
|
+
async function fetch_when_free(owner, url, init = {}, { asking, busy } = {}) {
|
|
29
|
+
const started = Date.now();
|
|
30
|
+
for (let n = 1;; n++) {
|
|
31
|
+
asking?.();
|
|
32
|
+
const res = await owner.task((signal) => fetch(url, {
|
|
33
|
+
...init,
|
|
34
|
+
signal
|
|
35
|
+
}));
|
|
36
|
+
if (res === STALE || res.status !== 409) return res;
|
|
37
|
+
const waited = Date.now() - started;
|
|
38
|
+
if (n >= PROFILER_BUSY.attempts || waited > PROFILER_BUSY.deadline) return res;
|
|
39
|
+
busy?.(waited);
|
|
40
|
+
const wait = busy_wait(res);
|
|
41
|
+
res.body?.cancel().catch(() => {});
|
|
42
|
+
if (await owner.task(() => new Promise((r) => owner.timeout(r, wait))) === STALE) return STALE;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export { fetch_when_free };
|
package/dist/profiler/client.js
CHANGED
|
@@ -1,32 +1,13 @@
|
|
|
1
1
|
// src/profiler/client.ts
|
|
2
2
|
const BEACON_KEY = Symbol.for("ogygia.beacon");
|
|
3
|
-
const
|
|
4
|
-
const waiting = [];
|
|
5
|
-
let retrying = false;
|
|
3
|
+
const WAITING_KEY = Symbol.for("ogygia.beacon.waiting");
|
|
6
4
|
function beacon_mark(...args) {
|
|
7
5
|
if (typeof document === "undefined") return;
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
6
|
+
const g = globalThis;
|
|
7
|
+
const b = g[BEACON_KEY];
|
|
8
|
+
if (b) return b.beacon_mark(...args);
|
|
9
|
+
const waiting = g[WAITING_KEY] ??= [];
|
|
14
10
|
if (waiting.length < 400) waiting.push(args);
|
|
15
|
-
if (retrying) return;
|
|
16
|
-
retrying = true;
|
|
17
|
-
let tries = 0;
|
|
18
|
-
const retry = () => {
|
|
19
|
-
const later = page_beacon();
|
|
20
|
-
if (later) {
|
|
21
|
-
retrying = false;
|
|
22
|
-
for (const w of waiting.splice(0)) later.beacon_mark(...w);
|
|
23
|
-
} else if (++tries < 40) setTimeout(retry, 250);
|
|
24
|
-
else {
|
|
25
|
-
retrying = false;
|
|
26
|
-
waiting.length = 0;
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
setTimeout(retry, 250);
|
|
30
11
|
}
|
|
31
12
|
const now = () => typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
|
|
32
13
|
const is_thenable = (v) => !!v && typeof v.then === "function";
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { STALE, flights } from "@ogygia/machine";
|
|
1
2
|
import { PROFILER_MAPS_GZ_PREFIX } from "@ogygia/core/internal/compiler-ids";
|
|
2
3
|
// src/profiler/embedded-maps.ts
|
|
3
|
-
let loaded;
|
|
4
4
|
let ready = null;
|
|
5
5
|
function load_embedded_maps() {
|
|
6
|
-
loaded
|
|
6
|
+
return loaded.get().then((maps) => maps === STALE ? null : maps);
|
|
7
|
+
}
|
|
8
|
+
/** The read, once for the process (it never fails: an unreadable payload is no maps). */
|
|
9
|
+
const loaded = flights({
|
|
10
|
+
name: "profiler embedded maps",
|
|
11
|
+
run: () => import("virtual:ogygia/profiler-maps").then(async (m) => {
|
|
7
12
|
let raw = m.default;
|
|
8
13
|
if (typeof raw === "string" && raw.startsWith(PROFILER_MAPS_GZ_PREFIX)) raw = (await import("node:zlib")).gunzipSync(Buffer.from(raw.slice(PROFILER_MAPS_GZ_PREFIX.length), "base64")).toString("utf8");
|
|
9
14
|
if (typeof raw !== "string" || !raw.startsWith("{")) return null;
|
|
@@ -14,9 +19,8 @@ function load_embedded_maps() {
|
|
|
14
19
|
...p.sources && typeof p.sources === "object" ? { sources: p.sources } : {},
|
|
15
20
|
...p.chunks && typeof p.chunks === "object" ? { chunks: p.chunks } : {}
|
|
16
21
|
} : null;
|
|
17
|
-
}).catch(() => null)
|
|
18
|
-
|
|
19
|
-
}
|
|
22
|
+
}).catch(() => null)
|
|
23
|
+
});
|
|
20
24
|
/** the maps once loaded (synchronous readers: the module map lookup), else null */
|
|
21
25
|
const embedded_maps = () => ready;
|
|
22
26
|
/** a script's key in the payload: its path under the server output (`chunks/format.js`), whether it
|
package/dist/profiler/gc.d.ts
CHANGED
|
@@ -127,6 +127,11 @@ export declare function gc_kind(kind: number): GcEvent["kind"];
|
|
|
127
127
|
export declare function heap_sites(head: HeapNode, dict: Record<string, AllocSite>): Map<string, number>;
|
|
128
128
|
/** What was allocated between two reads: per site, the growth of its cumulative bytes. */
|
|
129
129
|
export declare function slice_delta(prev: Map<string, number>, cur: Map<string, number>): Record<string, number>;
|
|
130
|
+
/**
|
|
131
|
+
* Each pause of a profiled window charged to the allocations before it, and each allocator to
|
|
132
|
+
* the pause time it caused.
|
|
133
|
+
* @heisenbench n log n in input
|
|
134
|
+
*/
|
|
130
135
|
export declare function attribute_gc(input: {
|
|
131
136
|
slices: AllocSlice[];
|
|
132
137
|
events: GcEvent[];
|
package/dist/profiler/gc.js
CHANGED
|
@@ -97,20 +97,58 @@ function slice_delta(prev, cur) {
|
|
|
97
97
|
const r1 = (n) => Math.round(n * 10) / 10;
|
|
98
98
|
const r2 = (n) => Math.round(n * 100) / 100;
|
|
99
99
|
const mb = (b) => r1(b / 1048576);
|
|
100
|
+
/**
|
|
101
|
+
* What was running at each moment of `times`: the first segment (in the timeline's order) that
|
|
102
|
+
* holds it, else the last to end before it, within 50 ms. A collector, idle or profiler segment is
|
|
103
|
+
* not the app's own work. The moments are swept in order, so the timeline is read about once
|
|
104
|
+
* instead of once per moment.
|
|
105
|
+
*/
|
|
106
|
+
function running_at_each(segs, times) {
|
|
107
|
+
const work = segs.filter((s) => s.category !== "gc" && s.category !== "idle" && s.category !== "profiler");
|
|
108
|
+
const at = [...new Set(times)].filter((t) => !Number.isNaN(t)).sort((a, b) => a - b);
|
|
109
|
+
const found = [];
|
|
110
|
+
const open = Array.from({ length: at.length + 1 }, (_, k) => k);
|
|
111
|
+
const next_open = (k) => {
|
|
112
|
+
while (open[k] !== k) k = open[k] = open[open[k]];
|
|
113
|
+
return k;
|
|
114
|
+
};
|
|
115
|
+
for (const s of work) {
|
|
116
|
+
if (!(s.t0 <= s.t1)) continue;
|
|
117
|
+
let lo = 0;
|
|
118
|
+
let hi = at.length;
|
|
119
|
+
while (lo < hi) {
|
|
120
|
+
const mid = lo + hi >>> 1;
|
|
121
|
+
if (at[mid] < s.t0) lo = mid + 1;
|
|
122
|
+
else hi = mid;
|
|
123
|
+
}
|
|
124
|
+
for (let k = next_open(lo); k < at.length && at[k] <= s.t1; k = next_open(k + 1)) {
|
|
125
|
+
found[k] = s;
|
|
126
|
+
open[k] = k + 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const by_end = work.map((s, i) => ({
|
|
130
|
+
s,
|
|
131
|
+
i
|
|
132
|
+
})).filter(({ s }) => !Number.isNaN(s.t1)).sort((a, b) => a.s.t1 - b.s.t1 || a.i - b.i);
|
|
133
|
+
const out = /* @__PURE__ */ new Map();
|
|
134
|
+
let best;
|
|
135
|
+
for (let k = 0, j = 0; k < at.length; k++) {
|
|
136
|
+
for (; j < by_end.length && by_end[j].s.t1 <= at[k]; j++) if (!best || by_end[j].s.t1 > best.t1) best = by_end[j].s;
|
|
137
|
+
out.set(at[k], found[k] ?? (best && at[k] - best.t1 <= 50 ? best : void 0));
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Each pause of a profiled window charged to the allocations before it, and each allocator to
|
|
143
|
+
* the pause time it caused.
|
|
144
|
+
* @heisenbench n log n in input
|
|
145
|
+
*/
|
|
100
146
|
function attribute_gc(input) {
|
|
101
147
|
const { slices, dict } = input;
|
|
102
148
|
const events = [...input.events].sort((a, b) => a.t - b.t);
|
|
103
149
|
const single = slices.length === 1;
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
let best;
|
|
107
|
-
for (const s of segs) {
|
|
108
|
-
if (s.category === "gc" || s.category === "idle" || s.category === "profiler") continue;
|
|
109
|
-
if (s.t0 <= t && t <= s.t1) return s;
|
|
110
|
-
if (s.t1 <= t && (!best || s.t1 > best.t1)) best = s;
|
|
111
|
-
}
|
|
112
|
-
return best && t - best.t1 <= 50 ? best : void 0;
|
|
113
|
-
};
|
|
150
|
+
const run_by_t = running_at_each(input.running ?? [], events.map((e) => e.t));
|
|
151
|
+
const running_at = (t) => run_by_t.get(t);
|
|
114
152
|
const ours = (k) => {
|
|
115
153
|
const c = dict[k]?.category;
|
|
116
154
|
return c !== "v8" && c !== "gc" && c !== "profiler";
|
|
@@ -121,10 +159,36 @@ function attribute_gc(input) {
|
|
|
121
159
|
return d ? `${d.name}|${d.url}|${d.line}|${d.caller ?? ""}|${d.component ?? ""}` : k;
|
|
122
160
|
};
|
|
123
161
|
const first_key = {};
|
|
162
|
+
const by_t0 = slices.map((s, i) => ({
|
|
163
|
+
s,
|
|
164
|
+
i
|
|
165
|
+
})).filter(({ s }) => s.t1 - s.t0 > 0).sort((a, b) => a.s.t0 - b.s.t0 || a.i - b.i);
|
|
166
|
+
const reach = [];
|
|
167
|
+
for (const { s } of by_t0) reach.push(Math.max(reach[reach.length - 1] ?? -Infinity, s.t1));
|
|
168
|
+
const read_in_order = by_t0.every((x, j) => j === 0 || by_t0[j - 1].i < x.i);
|
|
169
|
+
const first_where = (holds) => {
|
|
170
|
+
let lo = 0;
|
|
171
|
+
let hi = by_t0.length;
|
|
172
|
+
while (lo < hi) {
|
|
173
|
+
const mid = lo + hi >>> 1;
|
|
174
|
+
if (holds(mid)) hi = mid;
|
|
175
|
+
else lo = mid + 1;
|
|
176
|
+
}
|
|
177
|
+
return lo;
|
|
178
|
+
};
|
|
124
179
|
const between = (from, to) => {
|
|
125
180
|
const acc = {};
|
|
126
181
|
let profiler_bytes = 0;
|
|
127
|
-
|
|
182
|
+
if (!(from < to)) return {
|
|
183
|
+
app: acc,
|
|
184
|
+
profiler_bytes
|
|
185
|
+
};
|
|
186
|
+
const hits = [];
|
|
187
|
+
const end = first_where((j) => by_t0[j].s.t0 >= to);
|
|
188
|
+
for (let j = first_where((j) => reach[j] > from); j < end; j++) if (by_t0[j].s.t1 > from) hits.push(by_t0[j].i);
|
|
189
|
+
if (!read_in_order) hits.sort((a, b) => a - b);
|
|
190
|
+
for (const i of hits) {
|
|
191
|
+
const s = slices[i];
|
|
128
192
|
const len = s.t1 - s.t0;
|
|
129
193
|
if (len <= 0 || s.t1 <= from || s.t0 > to) continue;
|
|
130
194
|
const w = Math.max(0, Math.min(1, (Math.min(s.t1, to) - Math.max(s.t0, from)) / len));
|