@oh-my-pi/pi-utils 15.9.5 → 15.10.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/types/logger.d.ts +7 -5
- package/dist/types/module-timer.d.ts +1 -0
- package/dist/types/timing-buffer.d.ts +22 -0
- package/package.json +2 -2
- package/src/logger.ts +189 -22
- package/src/module-timer.ts +148 -0
- package/src/timing-buffer.ts +47 -0
package/dist/types/logger.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export declare function info(message: string, context?: Record<string, unknown>)
|
|
|
31
31
|
* @param context - The context to log.
|
|
32
32
|
*/
|
|
33
33
|
export declare function debug(message: string, context?: Record<string, unknown>): void;
|
|
34
|
+
export declare function timingModeIncludes(option: "full" | "x"): boolean;
|
|
35
|
+
export declare function shouldExitAfterTimings(): boolean;
|
|
34
36
|
/**
|
|
35
37
|
* Print collected timings as an indented tree.
|
|
36
38
|
* Each span shows wall duration; parents with children also show "(self)" for unattributed time.
|
|
@@ -39,16 +41,16 @@ export declare function debug(message: string, context?: Record<string, unknown>
|
|
|
39
41
|
export declare function printTimings(): void;
|
|
40
42
|
/**
|
|
41
43
|
* Begin recording startup timings under a new root span.
|
|
42
|
-
* Idempotent: a second call while already recording is a no-op so
|
|
43
|
-
*
|
|
44
|
+
* Idempotent: a second call while already recording is a no-op, so an explicit
|
|
45
|
+
* starter (main.ts) and any future early starter can coexist.
|
|
44
46
|
*/
|
|
45
47
|
export declare function startTiming(): void;
|
|
46
48
|
/**
|
|
47
49
|
* Record an externally-measured span as a leaf child of the active span (or root
|
|
48
|
-
* when no span is active). Used by
|
|
49
|
-
*
|
|
50
|
+
* when no span is active). Used by {@link spliceModuleLoadBuffer} to fold
|
|
51
|
+
* preload-captured module windows into the tree.
|
|
50
52
|
*/
|
|
51
|
-
export declare function recordModuleLoadSpan(path: string, start: number, durationMs: number): void;
|
|
53
|
+
export declare function recordModuleLoadSpan(path: string, start: number, durationMs: number, bodyMs?: number, imports?: string[]): void;
|
|
52
54
|
/**
|
|
53
55
|
* End timing window and clear buffers.
|
|
54
56
|
*/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared contract between the {@link module-timer} preload and {@link logger}'s
|
|
3
|
+
* timing tree. Kept in its own dependency-free module so the preload can import
|
|
4
|
+
* it without pulling in winston (via logger) and the logger can drain the buffer
|
|
5
|
+
* without importing the Bun-plugin preload.
|
|
6
|
+
*/
|
|
7
|
+
export interface ModuleLoadEvent {
|
|
8
|
+
/** Absolute or Bun-resolved module path. */
|
|
9
|
+
path: string;
|
|
10
|
+
/** `performance.now()` timestamp captured at Bun `onLoad` entry. */
|
|
11
|
+
start: number;
|
|
12
|
+
/** Inclusive module window: `onLoad` entry → appended final marker. */
|
|
13
|
+
durationMs: number;
|
|
14
|
+
/** Own top-level body / TLA time: prepended body marker → appended final marker. */
|
|
15
|
+
bodyMs?: number;
|
|
16
|
+
/** Resolved static children imported by this module. */
|
|
17
|
+
imports: string[];
|
|
18
|
+
}
|
|
19
|
+
/** The append-only buffer the preload pushes into (created on first access). */
|
|
20
|
+
export declare function moduleLoadBuffer(): ModuleLoadEvent[];
|
|
21
|
+
/** Drain and return all buffered events, leaving the buffer empty. */
|
|
22
|
+
export declare function drainModuleLoadEvents(): ModuleLoadEvent[];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-utils",
|
|
4
|
-
"version": "15.
|
|
4
|
+
"version": "15.10.0",
|
|
5
5
|
"description": "Shared utilities for pi packages",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"fmt": "biome format --write ."
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@oh-my-pi/pi-natives": "15.
|
|
34
|
+
"@oh-my-pi/pi-natives": "15.10.0",
|
|
35
35
|
"beautiful-mermaid": "^1.1.3",
|
|
36
36
|
"handlebars": "^4.7.9",
|
|
37
37
|
"winston": "^3.19.0",
|
package/src/logger.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { isPromise } from "node:util/types";
|
|
|
15
15
|
import winston from "winston";
|
|
16
16
|
import DailyRotateFile from "winston-daily-rotate-file";
|
|
17
17
|
import { getLogsDir } from "./dirs";
|
|
18
|
+
import { drainModuleLoadEvents } from "./timing-buffer";
|
|
18
19
|
|
|
19
20
|
/** Ensure a logs directory exists; return the resolved path. */
|
|
20
21
|
function ensureDir(dir: string): string {
|
|
@@ -166,12 +167,36 @@ interface Span {
|
|
|
166
167
|
children: Span[];
|
|
167
168
|
/** Marker / point event without a duration. */
|
|
168
169
|
point?: boolean;
|
|
170
|
+
/** Absolute module path for module-load spans. */
|
|
171
|
+
modulePath?: string;
|
|
172
|
+
/** Own top-level module body / TLA duration for module-load spans. */
|
|
173
|
+
moduleBodyMs?: number;
|
|
174
|
+
/** Resolved static imports for module-load spans. */
|
|
175
|
+
moduleImports?: string[];
|
|
169
176
|
}
|
|
170
|
-
|
|
171
177
|
const spanStorage = new AsyncLocalStorage<Span>();
|
|
172
178
|
let gRootSpan: Span | undefined;
|
|
173
179
|
let gRecordTimings = false;
|
|
174
180
|
|
|
181
|
+
export function timingModeIncludes(option: "full" | "x"): boolean {
|
|
182
|
+
const value = process.env.PI_TIMING;
|
|
183
|
+
if (!value) return false;
|
|
184
|
+
if (value === option) return true;
|
|
185
|
+
let start = 0;
|
|
186
|
+
for (let i = 0; i <= value.length; i++) {
|
|
187
|
+
const code = i === value.length ? 44 : value.charCodeAt(i);
|
|
188
|
+
const separator = code === 44 || code === 58 || code === 59 || code === 43 || code <= 32;
|
|
189
|
+
if (!separator) continue;
|
|
190
|
+
if (i > start && value.slice(start, i) === option) return true;
|
|
191
|
+
start = i + 1;
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function shouldExitAfterTimings(): boolean {
|
|
197
|
+
return timingModeIncludes("x") || timingModeIncludes("full");
|
|
198
|
+
}
|
|
199
|
+
|
|
175
200
|
/**
|
|
176
201
|
* Print collected timings as an indented tree.
|
|
177
202
|
* Each span shows wall duration; parents with children also show "(self)" for unattributed time.
|
|
@@ -184,9 +209,22 @@ export function printTimings(): void {
|
|
|
184
209
|
}
|
|
185
210
|
|
|
186
211
|
gRootSpan.end = performance.now();
|
|
212
|
+
// Splice any preload-captured module-load events into the tree as root
|
|
213
|
+
// children and back-extend the root window over them, so the static-import
|
|
214
|
+
// phase that ran before the first explicit marker becomes visible (the
|
|
215
|
+
// `(modules)` summary below) instead of being lumped into the opaque
|
|
216
|
+
// `(before instrumentation)` figure.
|
|
217
|
+
spliceModuleLoadBuffer();
|
|
187
218
|
const lines: string[] = [];
|
|
188
219
|
lines.push("");
|
|
189
220
|
lines.push("--- Startup timings (hierarchical) ---");
|
|
221
|
+
// performance.now() shares the process-start origin, so the root span's start
|
|
222
|
+
// is the wall time before the first marker — runtime init plus any module
|
|
223
|
+
// loads not captured below. With the module-load preload active this shrinks
|
|
224
|
+
// to ~runtime init because the load phase is back-folded into the window.
|
|
225
|
+
if (gRootSpan.start > LOGGED_TIMING_THRESHOLD_MS) {
|
|
226
|
+
lines.push(`(before instrumentation): ${fmtMs(gRootSpan.start)} [runtime init + module load]`);
|
|
227
|
+
}
|
|
190
228
|
const work: Span[] = [];
|
|
191
229
|
const loads: Span[] = [];
|
|
192
230
|
for (const child of gRootSpan.children) {
|
|
@@ -199,8 +237,14 @@ export function printTimings(): void {
|
|
|
199
237
|
if (loads.length > 0) {
|
|
200
238
|
printModuleLoadSummary(loads, 0, lines);
|
|
201
239
|
}
|
|
240
|
+
// Surface the root's own unattributed time so the gap between the visible
|
|
241
|
+
// top-level spans and Total isn't silently swallowed.
|
|
242
|
+
const rootSelf = selfTimeOf(gRootSpan);
|
|
243
|
+
if (gRootSpan.children.length > 0 && rootSelf > LOGGED_TIMING_THRESHOLD_MS) {
|
|
244
|
+
lines.push(`(unattributed self): ${fmtMs(rootSelf)}`);
|
|
245
|
+
}
|
|
202
246
|
const totalMs = (gRootSpan.end - gRootSpan.start).toFixed(1);
|
|
203
|
-
lines.push(`Total: ${totalMs}ms`);
|
|
247
|
+
lines.push(`Total: ${totalMs}ms (since first marker)`);
|
|
204
248
|
lines.push("--------------------------------------");
|
|
205
249
|
lines.push("");
|
|
206
250
|
console.error(lines.join("\n"));
|
|
@@ -209,8 +253,8 @@ export function printTimings(): void {
|
|
|
209
253
|
|
|
210
254
|
/**
|
|
211
255
|
* Begin recording startup timings under a new root span.
|
|
212
|
-
* Idempotent: a second call while already recording is a no-op so
|
|
213
|
-
*
|
|
256
|
+
* Idempotent: a second call while already recording is a no-op, so an explicit
|
|
257
|
+
* starter (main.ts) and any future early starter can coexist.
|
|
214
258
|
*/
|
|
215
259
|
export function startTiming(): void {
|
|
216
260
|
if (gRecordTimings) return;
|
|
@@ -225,10 +269,16 @@ export function startTiming(): void {
|
|
|
225
269
|
|
|
226
270
|
/**
|
|
227
271
|
* Record an externally-measured span as a leaf child of the active span (or root
|
|
228
|
-
* when no span is active). Used by
|
|
229
|
-
*
|
|
272
|
+
* when no span is active). Used by {@link spliceModuleLoadBuffer} to fold
|
|
273
|
+
* preload-captured module windows into the tree.
|
|
230
274
|
*/
|
|
231
|
-
export function recordModuleLoadSpan(
|
|
275
|
+
export function recordModuleLoadSpan(
|
|
276
|
+
path: string,
|
|
277
|
+
start: number,
|
|
278
|
+
durationMs: number,
|
|
279
|
+
bodyMs?: number,
|
|
280
|
+
imports: string[] = [],
|
|
281
|
+
): void {
|
|
232
282
|
if (!gRecordTimings || !gRootSpan) return;
|
|
233
283
|
const parent = spanStorage.getStore() ?? gRootSpan;
|
|
234
284
|
const span: Span = {
|
|
@@ -237,10 +287,32 @@ export function recordModuleLoadSpan(path: string, start: number, durationMs: nu
|
|
|
237
287
|
end: start + durationMs,
|
|
238
288
|
parent,
|
|
239
289
|
children: [],
|
|
290
|
+
modulePath: path,
|
|
291
|
+
moduleBodyMs: bodyMs,
|
|
292
|
+
moduleImports: imports,
|
|
240
293
|
};
|
|
241
294
|
parent.children.push(span);
|
|
242
295
|
}
|
|
243
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Drain the preload's module-load buffer (see module-timer.ts) into the tree as
|
|
299
|
+
* `load:` children of the root, then back-extend the root window to the earliest
|
|
300
|
+
* captured read so the pre-marker load phase is counted in Total rather than
|
|
301
|
+
* hidden as `(before instrumentation)`. No-op when nothing was captured (e.g. no
|
|
302
|
+
* `--preload`, or a compiled binary where module reads are not interceptable).
|
|
303
|
+
*/
|
|
304
|
+
function spliceModuleLoadBuffer(): void {
|
|
305
|
+
if (!gRootSpan) return;
|
|
306
|
+
const events = drainModuleLoadEvents();
|
|
307
|
+
if (events.length === 0) return;
|
|
308
|
+
let earliest = gRootSpan.start;
|
|
309
|
+
for (const event of events) {
|
|
310
|
+
recordModuleLoadSpan(event.path, event.start, event.durationMs, event.bodyMs, event.imports);
|
|
311
|
+
if (event.start < earliest) earliest = event.start;
|
|
312
|
+
}
|
|
313
|
+
gRootSpan.start = earliest;
|
|
314
|
+
}
|
|
315
|
+
|
|
244
316
|
function shortenLoadPath(p: string): string {
|
|
245
317
|
const cwd = process.cwd();
|
|
246
318
|
if (p.startsWith(`${cwd}/`)) return p.slice(cwd.length + 1);
|
|
@@ -296,6 +368,16 @@ function fmtMs(ms: number): string {
|
|
|
296
368
|
|
|
297
369
|
const MODULE_LOAD_PREFIX = "load:";
|
|
298
370
|
const MODULE_LOAD_VERBOSE_TOP = 10;
|
|
371
|
+
const MODULE_TREE_MAX_DEPTH = 5;
|
|
372
|
+
const MODULE_TREE_ROOT_TOP = 5;
|
|
373
|
+
const MODULE_TREE_CHILD_TOP = 8;
|
|
374
|
+
|
|
375
|
+
interface ModuleTimingNode {
|
|
376
|
+
span: Span;
|
|
377
|
+
children: ModuleTimingNode[];
|
|
378
|
+
parents: number;
|
|
379
|
+
body: number;
|
|
380
|
+
}
|
|
299
381
|
|
|
300
382
|
function isModuleLoadSpan(span: Span): boolean {
|
|
301
383
|
return span.op.startsWith(MODULE_LOAD_PREFIX);
|
|
@@ -330,33 +412,118 @@ function printSpan(span: Span, depth: number, lines: string[]): void {
|
|
|
330
412
|
}
|
|
331
413
|
}
|
|
332
414
|
|
|
333
|
-
/**
|
|
415
|
+
/** Render module-load spans as a dependency-aware DAG/tree. */
|
|
334
416
|
function printModuleLoadSummary(loads: Span[], depth: number, lines: string[]): void {
|
|
335
417
|
const childIndent = " ".repeat(depth);
|
|
336
418
|
const grandIndent = " ".repeat(depth + 1);
|
|
337
419
|
let unionStart = Number.POSITIVE_INFINITY;
|
|
338
420
|
let unionEnd = 0;
|
|
339
|
-
let totalSelf = 0;
|
|
340
421
|
for (const span of loads) {
|
|
341
422
|
if (span.end === undefined) continue;
|
|
342
423
|
if (span.start < unionStart) unionStart = span.start;
|
|
343
424
|
if (span.end > unionEnd) unionEnd = span.end;
|
|
344
|
-
totalSelf += span.end - span.start;
|
|
345
425
|
}
|
|
346
426
|
const wall = unionEnd > unionStart ? unionEnd - unionStart : 0;
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
427
|
+
const nodes = buildModuleTimingGraph(loads);
|
|
428
|
+
lines.push(`${childIndent}(modules): ${loads.length} loaded, wall ${fmtMs(wall)}`);
|
|
429
|
+
if (nodes.length === 0) return;
|
|
430
|
+
|
|
431
|
+
const showAll = timingModeIncludes("full");
|
|
432
|
+
const byBody = [...nodes].sort(compareModuleNodes);
|
|
433
|
+
const topBody = showAll ? byBody : byBody.slice(0, MODULE_LOAD_VERBOSE_TOP);
|
|
434
|
+
lines.push(`${grandIndent}top body/TLA:`);
|
|
435
|
+
for (const node of topBody) {
|
|
436
|
+
if (!showAll && node.body < LOGGED_TIMING_THRESHOLD_MS) break;
|
|
437
|
+
lines.push(`${grandIndent} ${node.span.op}: body ${fmtMs(node.body)} (total ${fmtMs(durationOf(node.span))})`);
|
|
438
|
+
}
|
|
439
|
+
if (!showAll && byBody.length > MODULE_LOAD_VERBOSE_TOP) {
|
|
440
|
+
lines.push(`${grandIndent} … ${byBody.length - MODULE_LOAD_VERBOSE_TOP} more (PI_TIMING=full to show all)`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const roots = nodes.filter(node => node.parents === 0);
|
|
444
|
+
const treeRoots = (roots.length > 0 ? roots : nodes).sort((a, b) => durationOf(b.span) - durationOf(a.span));
|
|
445
|
+
const visibleRoots = showAll ? treeRoots : treeRoots.slice(0, MODULE_TREE_ROOT_TOP);
|
|
446
|
+
lines.push(`${grandIndent}tree:`);
|
|
447
|
+
const rendered = new Set<string>();
|
|
448
|
+
for (const node of visibleRoots) {
|
|
449
|
+
renderModuleTimingNode(node, depth + 2, lines, rendered, new Set<string>(), showAll);
|
|
450
|
+
}
|
|
451
|
+
if (!showAll && treeRoots.length > MODULE_TREE_ROOT_TOP) {
|
|
452
|
+
lines.push(
|
|
453
|
+
`${grandIndent} … ${treeRoots.length - MODULE_TREE_ROOT_TOP} more roots (PI_TIMING=full to show all)`,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function buildModuleTimingGraph(loads: Span[]): ModuleTimingNode[] {
|
|
459
|
+
const nodes = new Map<string, ModuleTimingNode>();
|
|
460
|
+
for (const span of loads) {
|
|
461
|
+
if (!span.modulePath || span.end === undefined) continue;
|
|
462
|
+
nodes.set(span.modulePath, { span, children: [], parents: 0, body: span.moduleBodyMs ?? 0 });
|
|
463
|
+
}
|
|
464
|
+
for (const node of nodes.values()) {
|
|
465
|
+
for (const childPath of node.span.moduleImports ?? []) {
|
|
466
|
+
const child = nodes.get(childPath);
|
|
467
|
+
if (!child || child === node) continue;
|
|
468
|
+
node.children.push(child);
|
|
469
|
+
child.parents++;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
for (const node of nodes.values()) {
|
|
473
|
+
node.children.sort(compareModuleNodes);
|
|
474
|
+
}
|
|
475
|
+
return [...nodes.values()];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function compareModuleNodes(a: ModuleTimingNode, b: ModuleTimingNode): number {
|
|
479
|
+
const bodyDiff = b.body - a.body;
|
|
480
|
+
if (Math.abs(bodyDiff) > 0.001) return bodyDiff;
|
|
481
|
+
return durationOf(b.span) - durationOf(a.span);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function renderModuleTimingNode(
|
|
485
|
+
node: ModuleTimingNode,
|
|
486
|
+
depth: number,
|
|
487
|
+
lines: string[],
|
|
488
|
+
rendered: Set<string>,
|
|
489
|
+
ancestors: Set<string>,
|
|
490
|
+
showAll: boolean,
|
|
491
|
+
): void {
|
|
492
|
+
const path = node.span.modulePath;
|
|
493
|
+
if (!path) return;
|
|
494
|
+
const indent = " ".repeat(depth);
|
|
495
|
+
const total = durationOf(node.span);
|
|
496
|
+
if (!showAll && total < LOGGED_TIMING_THRESHOLD_MS && node.children.length === 0) return;
|
|
497
|
+
const wait = Math.max(0, total - node.body);
|
|
498
|
+
const shared = node.parents > 1 ? " [shared]" : "";
|
|
499
|
+
const timing =
|
|
500
|
+
node.body > LOGGED_TIMING_THRESHOLD_MS || node.children.length > 0
|
|
501
|
+
? ` (body ${fmtMs(node.body)}, wait ${fmtMs(wait)})`
|
|
502
|
+
: "";
|
|
503
|
+
const alreadyRendered = rendered.has(path);
|
|
504
|
+
const cycle = ancestors.has(path);
|
|
505
|
+
const suffix = cycle ? " [cycle]" : alreadyRendered ? " [already shown]" : "";
|
|
506
|
+
lines.push(`${indent}${node.span.op}: ${fmtMs(total)}${timing}${shared}${suffix}`);
|
|
507
|
+
if (cycle || alreadyRendered) return;
|
|
508
|
+
rendered.add(path);
|
|
509
|
+
ancestors.add(path);
|
|
510
|
+
if (!showAll && ancestors.size >= MODULE_TREE_MAX_DEPTH) {
|
|
511
|
+
if (node.children.length > 0) {
|
|
512
|
+
lines.push(`${indent} … ${node.children.length} imports deeper (PI_TIMING=full to show all)`);
|
|
513
|
+
}
|
|
514
|
+
ancestors.delete(path);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const visibleChildren = showAll ? node.children : node.children.slice(0, MODULE_TREE_CHILD_TOP);
|
|
518
|
+
for (const child of visibleChildren) {
|
|
519
|
+
renderModuleTimingNode(child, depth + 1, lines, rendered, ancestors, showAll);
|
|
520
|
+
}
|
|
521
|
+
if (!showAll && node.children.length > MODULE_TREE_CHILD_TOP) {
|
|
522
|
+
lines.push(
|
|
523
|
+
`${indent} … ${node.children.length - MODULE_TREE_CHILD_TOP} more imports (PI_TIMING=full to show all)`,
|
|
524
|
+
);
|
|
359
525
|
}
|
|
526
|
+
ancestors.delete(path);
|
|
360
527
|
}
|
|
361
528
|
|
|
362
529
|
/** A span is parallel if it overlaps a sibling that started before it. */
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module-load timing preload.
|
|
3
|
+
*
|
|
4
|
+
* `bun --preload .../module-timer.ts <entry>` installs Bun plugin hooks (only
|
|
5
|
+
* when `PI_TIMING` is set) that record an inclusive module window plus resolved
|
|
6
|
+
* static child edges:
|
|
7
|
+
*
|
|
8
|
+
* onLoad start → appended end marker after the module's top-level body
|
|
9
|
+
*
|
|
10
|
+
* Events are pushed into a process-global buffer that {@link logger.printTimings}
|
|
11
|
+
* drains and renders as a module DAG/tree. Each module row can therefore show
|
|
12
|
+
* both total time and `self` time after subtracting child module intervals.
|
|
13
|
+
*
|
|
14
|
+
* Why a preload (and not a normal import): Bun reads the *entire* statically
|
|
15
|
+
* reachable graph before evaluating any module, so hooks installed from inside
|
|
16
|
+
* that graph cannot observe its own loading — they only catch later dynamically
|
|
17
|
+
* loaded modules. A preload runs first, so it sees the static-import phase that
|
|
18
|
+
* dominates startup.
|
|
19
|
+
*
|
|
20
|
+
* Kept dependency-free on purpose: the sole import is Bun's `plugin`, so this is
|
|
21
|
+
* cheap to preload before pi-utils (and winston) exist. The buffer is shared with
|
|
22
|
+
* the logger via a registry Symbol so neither side needs to import the other.
|
|
23
|
+
*
|
|
24
|
+
* **What is measured:** an inclusive per-module window. `onLoad` stamps the
|
|
25
|
+
* start before reading source; the returned source has a tiny marker appended at
|
|
26
|
+
* the end of the module. That marker runs after Bun parses/transpiles the module
|
|
27
|
+
* and after any top-level await in that module completes, so the duration
|
|
28
|
+
* includes read + parse/transpile + dependency wait + top-level execution/TLA.
|
|
29
|
+
* If a module throws before its final statement, no end marker is recorded.
|
|
30
|
+
*
|
|
31
|
+
* **Tree shape:** `onResolve` observes importer → specifier edges and resolves
|
|
32
|
+
* them with `Bun.resolveSync` without taking over Bun's real resolution. The
|
|
33
|
+
* logger renders these edges as a DAG/tree and computes module `self` time by
|
|
34
|
+
* subtracting the union of child intervals, avoiding misleading flat inclusive
|
|
35
|
+
* totals.
|
|
36
|
+
*
|
|
37
|
+
* **Coverage limits:**
|
|
38
|
+
* - TS/TSX only — intercepting `node_modules` CJS `.js`/`.cjs` and forcing ESM
|
|
39
|
+
* breaks their default-export detection, so they are left to Bun's default path.
|
|
40
|
+
* - **Dev runs only.** In the compiled `omp` binary every module is pre-bundled
|
|
41
|
+
* into bunfs, so `onLoad` never fires; profile with a `bun --preload` dev run.
|
|
42
|
+
*/
|
|
43
|
+
import { plugin } from "bun";
|
|
44
|
+
import { moduleLoadBuffer } from "./timing-buffer";
|
|
45
|
+
|
|
46
|
+
// Restrict to TS/TSX only. node_modules ships CommonJS `.js`/`.cjs` that Bun
|
|
47
|
+
// auto-detects when loaded via its default path; if we intercept and return
|
|
48
|
+
// `{ contents, loader: "js" }`, Bun forces ESM and CJS modules fail to load
|
|
49
|
+
// (e.g. `Missing 'default' export`). Our own source tree (where the interesting
|
|
50
|
+
// timing lives) is uniformly TypeScript, so a TS-only filter is both safe and
|
|
51
|
+
// sufficient.
|
|
52
|
+
const MODULE_LOADER_FILTER = /\.[mc]?tsx?$/;
|
|
53
|
+
const MODULE_COMPLETE_KEY: symbol = Symbol.for("omp.moduleLoadComplete");
|
|
54
|
+
const MODULE_BODY_START_KEY: symbol = Symbol.for("omp.moduleBodyStart");
|
|
55
|
+
const STATIC_IMPORT_PATTERN =
|
|
56
|
+
/\b(?:import|export)\s+(?:type\s+)?(?:[^"']*?\s+from\s+)?["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
57
|
+
|
|
58
|
+
type CompleteStore = Record<symbol, ((path: string) => void) | undefined>;
|
|
59
|
+
|
|
60
|
+
function bodyStartMarker(path: string): string {
|
|
61
|
+
return `;globalThis[Symbol.for("omp.moduleBodyStart")]?.(${JSON.stringify(path)});\n`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function completionMarker(path: string): string {
|
|
65
|
+
return `\n;globalThis[Symbol.for("omp.moduleLoadComplete")]?.(${JSON.stringify(path)});\n`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function instrumentContents(path: string, contents: string): string {
|
|
69
|
+
const start = bodyStartMarker(path);
|
|
70
|
+
const end = completionMarker(path);
|
|
71
|
+
if (!contents.startsWith("#!")) return `${start}${contents}${end}`;
|
|
72
|
+
const newline = contents.indexOf("\n");
|
|
73
|
+
if (newline === -1) return `${contents}\n${start}${end}`;
|
|
74
|
+
return `${contents.slice(0, newline + 1)}${start}${contents.slice(newline + 1)}${end}`;
|
|
75
|
+
}
|
|
76
|
+
function importerDir(importer: string): string {
|
|
77
|
+
const slash = importer.lastIndexOf("/");
|
|
78
|
+
if (slash === -1) return ".";
|
|
79
|
+
return importer.slice(0, slash);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function childSetFor(importsByPath: Map<string, Set<string>>, path: string): Set<string> {
|
|
83
|
+
let children = importsByPath.get(path);
|
|
84
|
+
if (!children) {
|
|
85
|
+
children = new Set<string>();
|
|
86
|
+
importsByPath.set(path, children);
|
|
87
|
+
}
|
|
88
|
+
return children;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function addImportEdges(importsByPath: Map<string, Set<string>>, importer: string, contents: string): void {
|
|
92
|
+
STATIC_IMPORT_PATTERN.lastIndex = 0;
|
|
93
|
+
for (const match of contents.matchAll(STATIC_IMPORT_PATTERN)) {
|
|
94
|
+
const specifier = match[1] ?? match[2];
|
|
95
|
+
if (!specifier) continue;
|
|
96
|
+
try {
|
|
97
|
+
const resolved = Bun.resolveSync(specifier, importerDir(importer));
|
|
98
|
+
if (MODULE_LOADER_FILTER.test(resolved) && resolved !== importer) {
|
|
99
|
+
childSetFor(importsByPath, importer).add(resolved);
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Leave Bun's real resolver/runtime to surface any error. This scanner is only an observer.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (process.env.PI_TIMING) {
|
|
108
|
+
const buffer = moduleLoadBuffer();
|
|
109
|
+
const starts = new Map<string, number>();
|
|
110
|
+
const bodyStarts = new Map<string, number>();
|
|
111
|
+
const importsByPath = new Map<string, Set<string>>();
|
|
112
|
+
const store = globalThis as unknown as CompleteStore;
|
|
113
|
+
store[MODULE_BODY_START_KEY] = (path: string): void => {
|
|
114
|
+
bodyStarts.set(path, performance.now());
|
|
115
|
+
};
|
|
116
|
+
store[MODULE_COMPLETE_KEY] = (path: string): void => {
|
|
117
|
+
const start = starts.get(path);
|
|
118
|
+
if (start === undefined) return;
|
|
119
|
+
starts.delete(path);
|
|
120
|
+
const end = performance.now();
|
|
121
|
+
const bodyStart = bodyStarts.get(path);
|
|
122
|
+
bodyStarts.delete(path);
|
|
123
|
+
const imports = importsByPath.get(path);
|
|
124
|
+
buffer.push({
|
|
125
|
+
path,
|
|
126
|
+
start,
|
|
127
|
+
durationMs: end - start,
|
|
128
|
+
bodyMs: bodyStart === undefined ? undefined : end - bodyStart,
|
|
129
|
+
imports: imports ? [...imports] : [],
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
plugin({
|
|
134
|
+
name: "pi-module-load-timer",
|
|
135
|
+
setup(build) {
|
|
136
|
+
build.onLoad({ filter: MODULE_LOADER_FILTER }, async args => {
|
|
137
|
+
starts.set(args.path, performance.now());
|
|
138
|
+
childSetFor(importsByPath, args.path);
|
|
139
|
+
const contents = await Bun.file(args.path).text();
|
|
140
|
+
addImportEdges(importsByPath, args.path, contents);
|
|
141
|
+
return {
|
|
142
|
+
contents: instrumentContents(args.path, contents),
|
|
143
|
+
loader: args.path.endsWith(".tsx") ? "tsx" : "ts",
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared contract between the {@link module-timer} preload and {@link logger}'s
|
|
3
|
+
* timing tree. Kept in its own dependency-free module so the preload can import
|
|
4
|
+
* it without pulling in winston (via logger) and the logger can drain the buffer
|
|
5
|
+
* without importing the Bun-plugin preload.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ModuleLoadEvent {
|
|
9
|
+
/** Absolute or Bun-resolved module path. */
|
|
10
|
+
path: string;
|
|
11
|
+
/** `performance.now()` timestamp captured at Bun `onLoad` entry. */
|
|
12
|
+
start: number;
|
|
13
|
+
/** Inclusive module window: `onLoad` entry → appended final marker. */
|
|
14
|
+
durationMs: number;
|
|
15
|
+
/** Own top-level body / TLA time: prepended body marker → appended final marker. */
|
|
16
|
+
bodyMs?: number;
|
|
17
|
+
/** Resolved static children imported by this module. */
|
|
18
|
+
imports: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Registry-global key under which the preload accumulates module-load events.
|
|
23
|
+
* `Symbol.for` so both modules resolve the same symbol independently.
|
|
24
|
+
*/
|
|
25
|
+
const KEY: symbol = Symbol.for("omp.moduleLoadBuffer");
|
|
26
|
+
|
|
27
|
+
type Store = Record<symbol, ModuleLoadEvent[] | undefined>;
|
|
28
|
+
|
|
29
|
+
/** The append-only buffer the preload pushes into (created on first access). */
|
|
30
|
+
export function moduleLoadBuffer(): ModuleLoadEvent[] {
|
|
31
|
+
const store = globalThis as unknown as Store;
|
|
32
|
+
let buffer = store[KEY];
|
|
33
|
+
if (!buffer) {
|
|
34
|
+
buffer = [];
|
|
35
|
+
store[KEY] = buffer;
|
|
36
|
+
}
|
|
37
|
+
return buffer;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Drain and return all buffered events, leaving the buffer empty. */
|
|
41
|
+
export function drainModuleLoadEvents(): ModuleLoadEvent[] {
|
|
42
|
+
const store = globalThis as unknown as Store;
|
|
43
|
+
const buffer = store[KEY];
|
|
44
|
+
if (!buffer || buffer.length === 0) return [];
|
|
45
|
+
store[KEY] = [];
|
|
46
|
+
return buffer;
|
|
47
|
+
}
|