trace-engine-cli 0.0.0-stage → 0.1.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.
Files changed (51) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +342 -2
  3. package/dist/bin/trace-cli.d.ts +2 -0
  4. package/dist/bin/trace-cli.js +19 -0
  5. package/dist/src/cli.d.ts +1 -0
  6. package/dist/src/cli.js +477 -0
  7. package/dist/src/engine/adapter.d.ts +13 -0
  8. package/dist/src/engine/adapter.js +51 -0
  9. package/dist/src/engine/extractors/diff.d.ts +12 -0
  10. package/dist/src/engine/extractors/diff.js +100 -0
  11. package/dist/src/engine/extractors/events.d.ts +27 -0
  12. package/dist/src/engine/extractors/events.js +84 -0
  13. package/dist/src/engine/extractors/find.d.ts +12 -0
  14. package/dist/src/engine/extractors/find.js +100 -0
  15. package/dist/src/engine/extractors/insights.d.ts +15 -0
  16. package/dist/src/engine/extractors/insights.js +153 -0
  17. package/dist/src/engine/extractors/interactions.d.ts +7 -0
  18. package/dist/src/engine/extractors/interactions.js +55 -0
  19. package/dist/src/engine/extractors/navigations.d.ts +10 -0
  20. package/dist/src/engine/extractors/navigations.js +82 -0
  21. package/dist/src/engine/extractors/network.d.ts +10 -0
  22. package/dist/src/engine/extractors/network.js +105 -0
  23. package/dist/src/engine/extractors/overview.d.ts +11 -0
  24. package/dist/src/engine/extractors/overview.js +232 -0
  25. package/dist/src/engine/extractors/profile.d.ts +12 -0
  26. package/dist/src/engine/extractors/profile.js +83 -0
  27. package/dist/src/engine/extractors/scan.d.ts +37 -0
  28. package/dist/src/engine/extractors/scan.js +218 -0
  29. package/dist/src/engine/extractors/scheduling.d.ts +10 -0
  30. package/dist/src/engine/extractors/scheduling.js +89 -0
  31. package/dist/src/engine/extractors/shifts.d.ts +11 -0
  32. package/dist/src/engine/extractors/shifts.js +118 -0
  33. package/dist/src/engine/extractors/tasks.d.ts +13 -0
  34. package/dist/src/engine/extractors/tasks.js +156 -0
  35. package/dist/src/engine/helpers.d.ts +157 -0
  36. package/dist/src/engine/helpers.js +95 -0
  37. package/dist/src/engine/loader.d.ts +34 -0
  38. package/dist/src/engine/loader.js +142 -0
  39. package/dist/src/engine/types.d.ts +452 -0
  40. package/dist/src/engine/types.js +14 -0
  41. package/dist/src/formatters/index.d.ts +6 -0
  42. package/dist/src/formatters/index.js +66 -0
  43. package/dist/src/formatters/markdown.d.ts +15 -0
  44. package/dist/src/formatters/markdown.js +474 -0
  45. package/dist/src/formatters/toon.d.ts +1 -0
  46. package/dist/src/formatters/toon.js +17 -0
  47. package/dist/src/utils/filter.d.ts +47 -0
  48. package/dist/src/utils/filter.js +136 -0
  49. package/dist/src/utils/units.d.ts +24 -0
  50. package/dist/src/utils/units.js +57 -0
  51. package/package.json +45 -4
@@ -0,0 +1,477 @@
1
+ // Copyright 2026 Google LLC
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // https://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ import yargs from 'yargs';
15
+ import { hideBin } from 'yargs/helpers';
16
+ import { extractDiff } from './engine/extractors/diff.js';
17
+ import { extractEvents } from './engine/extractors/events.js';
18
+ import { extractFind } from './engine/extractors/find.js';
19
+ import { extractInsights } from './engine/extractors/insights.js';
20
+ import { extractInteractions } from './engine/extractors/interactions.js';
21
+ import { extractNavigations } from './engine/extractors/navigations.js';
22
+ import { extractNetwork } from './engine/extractors/network.js';
23
+ import { extractOverview } from './engine/extractors/overview.js';
24
+ import { extractProfile } from './engine/extractors/profile.js';
25
+ import { scanTraces } from './engine/extractors/scan.js';
26
+ import { extractScheduling } from './engine/extractors/scheduling.js';
27
+ import { extractShifts } from './engine/extractors/shifts.js';
28
+ import { extractTasks } from './engine/extractors/tasks.js';
29
+ import { loadTraceFromFile } from './engine/loader.js';
30
+ import { formatResult } from './formatters/index.js';
31
+ import { parseTimeRange, parseTimeValue, resolveTimeRange } from './utils/filter.js';
32
+ /**
33
+ * Splits a comma-separated argument into trimmed, non-empty tokens.
34
+ */
35
+ function parseCommaList(val) {
36
+ if (!val) {
37
+ return undefined;
38
+ }
39
+ const items = String(val)
40
+ .split(',')
41
+ .map((s) => s.trim())
42
+ .filter(Boolean);
43
+ return items.length > 0 ? items : undefined;
44
+ }
45
+ export async function runCli(argv) {
46
+ const cli = yargs(hideBin(argv))
47
+ .scriptName('trace-cli')
48
+ .usage('$0 [command] <file> [options]')
49
+ .option('format', {
50
+ alias: 'f',
51
+ type: 'string',
52
+ choices: ['toon', 'md'],
53
+ default: 'toon',
54
+ describe: 'Output format (toon or md)',
55
+ })
56
+ .option('trace-engine', {
57
+ type: 'string',
58
+ describe: 'Path to custom trace engine bundle',
59
+ })
60
+ .option('time-range', {
61
+ type: 'string',
62
+ describe: 'Time window (relative to trace start by default, e.g. "3500,4200", "3.5s,4.2s", "100ms-500ms")',
63
+ })
64
+ .option('absolute-timestamps', {
65
+ type: 'boolean',
66
+ default: false,
67
+ describe: 'Treat time ranges as raw absolute trace timestamps rather than relative to trace start (default: false)',
68
+ })
69
+ .command(['overview <file>', '$0 <file>'], 'High-level trace metadata, Core Web Vitals, and main thread category breakdown (default command)', (y) => y.positional('file', {
70
+ describe: 'Path to trace file (.json or .json.gz)',
71
+ type: 'string',
72
+ demandOption: true,
73
+ }), async (args) => {
74
+ const loaded = await loadTraceFromFile(args.file, args.traceEngine);
75
+ const rawRange = parseTimeRange(args.timeRange);
76
+ const timeRange = resolveTimeRange(rawRange, loaded.parsedTrace.data.Meta.traceBounds, args.absoluteTimestamps);
77
+ const result = extractOverview(loaded, { timeRange });
78
+ const isDefaultCmd = !args._.includes('overview');
79
+ console.log(formatResult(result, {
80
+ format: args.format,
81
+ type: 'overview',
82
+ includeHint: isDefaultCmd,
83
+ }));
84
+ })
85
+ .command('insights <file>', 'Diagnostic insight cards (LCP, INP, CLS, render-blocking, third-parties)', (y) => y
86
+ .positional('file', {
87
+ describe: 'Path to trace file (.json or .json.gz)',
88
+ type: 'string',
89
+ demandOption: true,
90
+ })
91
+ .option('category', {
92
+ type: 'string',
93
+ choices: ['lcp', 'inp', 'cls', 'render-blocking', 'third-parties'],
94
+ describe: 'Filter insights by specific diagnostic category',
95
+ })
96
+ .option('navigation', {
97
+ alias: ['nav'],
98
+ type: 'string',
99
+ describe: 'Navigation ID to inspect insights for',
100
+ }), async (args) => {
101
+ const loaded = await loadTraceFromFile(args.file, args.traceEngine);
102
+ const result = extractInsights(loaded, {
103
+ category: args.category,
104
+ navigationId: args.navigation,
105
+ });
106
+ console.log(formatResult(result, {
107
+ format: args.format,
108
+ type: 'insights',
109
+ }));
110
+ })
111
+ .command('tasks <file>', 'Long tasks, bottom-up and top-down TraceTree call aggregations', (y) => y
112
+ .positional('file', {
113
+ describe: 'Path to trace file (.json or .json.gz)',
114
+ type: 'string',
115
+ demandOption: true,
116
+ })
117
+ .option('min-duration', {
118
+ type: 'number',
119
+ default: 50,
120
+ describe: 'Minimum task duration threshold in milliseconds (default: 50ms)',
121
+ })
122
+ .option('tree', {
123
+ type: 'string',
124
+ choices: ['bottom-up', 'top-down'],
125
+ describe: "Generate aggregated call tree ('bottom-up' sorted by self-time, 'top-down' sorted by total duration)",
126
+ })
127
+ .option('top', {
128
+ alias: ['limit'],
129
+ type: 'number',
130
+ default: 20,
131
+ describe: 'Limit output to top N tasks (default: 20)',
132
+ }), async (args) => {
133
+ const loaded = await loadTraceFromFile(args.file, args.traceEngine);
134
+ const rawRange = parseTimeRange(args.timeRange);
135
+ const timeRange = resolveTimeRange(rawRange, loaded.parsedTrace.data.Meta.traceBounds, args.absoluteTimestamps);
136
+ const result = extractTasks(loaded, {
137
+ minDurationMs: args.minDuration,
138
+ tree: args.tree,
139
+ top: args.top,
140
+ timeRange,
141
+ });
142
+ console.log(formatResult(result, {
143
+ format: args.format,
144
+ type: 'tasks',
145
+ }));
146
+ })
147
+ .command('find <file>', 'Search for function execution instances, call frequency, and starvation intervals', (y) => y
148
+ .positional('file', {
149
+ describe: 'Path to trace file (.json or .json.gz)',
150
+ type: 'string',
151
+ demandOption: true,
152
+ })
153
+ .option('function', {
154
+ alias: ['fn', 'name'],
155
+ type: 'string',
156
+ demandOption: true,
157
+ describe: 'Function name or pattern to search for',
158
+ })
159
+ .option('top', {
160
+ alias: ['limit'],
161
+ type: 'number',
162
+ default: 50,
163
+ describe: 'Maximum number of invocation entries to list (default: 50)',
164
+ }), async (args) => {
165
+ const loaded = await loadTraceFromFile(args.file, args.traceEngine);
166
+ const rawRange = parseTimeRange(args.timeRange);
167
+ const timeRange = resolveTimeRange(rawRange, loaded.parsedTrace.data.Meta.traceBounds, args.absoluteTimestamps);
168
+ const result = extractFind(loaded, {
169
+ functionName: args.function,
170
+ timeRange,
171
+ limit: args.top,
172
+ });
173
+ console.log(formatResult(result, {
174
+ format: args.format,
175
+ type: 'find',
176
+ }));
177
+ })
178
+ .command('profile <file>', 'Time-bucketed CPU profiling progression across discrete intervals', (y) => y
179
+ .positional('file', {
180
+ describe: 'Path to trace file (.json or .json.gz)',
181
+ type: 'string',
182
+ demandOption: true,
183
+ })
184
+ .option('bucket', {
185
+ type: 'string',
186
+ describe: 'Bucket slice duration with optional unit (e.g. "50ms", "0.1s", "50", default: dynamic ~50ms)',
187
+ })
188
+ .option('top', {
189
+ alias: ['limit'],
190
+ type: 'number',
191
+ default: 3,
192
+ describe: 'Top N functions to include per time bucket (default: 3)',
193
+ }), async (args) => {
194
+ const loaded = await loadTraceFromFile(args.file, args.traceEngine);
195
+ const rawRange = parseTimeRange(args.timeRange);
196
+ const timeRange = resolveTimeRange(rawRange, loaded.parsedTrace.data.Meta.traceBounds, args.absoluteTimestamps);
197
+ const bucketMs = args.bucket ? parseTimeValue(String(args.bucket)) : undefined;
198
+ const result = extractProfile(loaded, {
199
+ timeRange,
200
+ bucketMs,
201
+ top: args.top,
202
+ });
203
+ console.log(formatResult(result, {
204
+ format: args.format,
205
+ type: 'profile',
206
+ }));
207
+ })
208
+ .command('diff <file>', 'Differential CPU profiling comparing two time windows in the trace', (y) => y
209
+ .positional('file', {
210
+ describe: 'Path to trace file (.json or .json.gz)',
211
+ type: 'string',
212
+ demandOption: true,
213
+ })
214
+ .option('window-a', {
215
+ type: 'string',
216
+ demandOption: true,
217
+ describe: 'Baseline time window (e.g. "3500,4000", "3.5s,4.0s", relative to start by default)',
218
+ })
219
+ .option('window-b', {
220
+ type: 'string',
221
+ demandOption: true,
222
+ describe: 'Comparison time window (e.g. "4000,4500", "4.0s,4.5s", relative to start by default)',
223
+ })
224
+ .option('top', {
225
+ alias: ['limit'],
226
+ type: 'number',
227
+ default: 20,
228
+ describe: 'Maximum differential entries to return (default: 20)',
229
+ }), async (args) => {
230
+ const loaded = await loadTraceFromFile(args.file, args.traceEngine);
231
+ const rawRangeA = parseTimeRange(args['window-a']);
232
+ const rawRangeB = parseTimeRange(args['window-b']);
233
+ const bounds = loaded.parsedTrace.data.Meta.traceBounds;
234
+ const isAbsolute = args.absoluteTimestamps;
235
+ const windowA = resolveTimeRange(rawRangeA, bounds, isAbsolute);
236
+ const windowB = resolveTimeRange(rawRangeB, bounds, isAbsolute);
237
+ if (!windowA || !windowB) {
238
+ throw new Error('Both --window-a and --window-b must be valid time ranges.');
239
+ }
240
+ const result = extractDiff(loaded, {
241
+ windowA,
242
+ windowB,
243
+ top: args.top,
244
+ });
245
+ console.log(formatResult(result, {
246
+ format: args.format,
247
+ type: 'diff',
248
+ }));
249
+ })
250
+ .command('scheduling <file>', 'Async task and animation frame scheduling, cancellation, and churn metrics', (y) => y.positional('file', {
251
+ describe: 'Path to trace file (.json or .json.gz)',
252
+ type: 'string',
253
+ demandOption: true,
254
+ }), async (args) => {
255
+ const loaded = await loadTraceFromFile(args.file, args.traceEngine);
256
+ const rawRange = parseTimeRange(args.timeRange);
257
+ const timeRange = resolveTimeRange(rawRange, loaded.parsedTrace.data.Meta.traceBounds, args.absoluteTimestamps);
258
+ const result = extractScheduling(loaded, { timeRange });
259
+ console.log(formatResult(result, {
260
+ format: args.format,
261
+ type: 'scheduling',
262
+ }));
263
+ })
264
+ .command('network <file>', 'Network request waterfall, timings, and initiator chains', (y) => y
265
+ .positional('file', {
266
+ describe: 'Path to trace file (.json or .json.gz)',
267
+ type: 'string',
268
+ demandOption: true,
269
+ })
270
+ .option('render-blocking', {
271
+ type: 'boolean',
272
+ default: false,
273
+ describe: 'Filter only render-blocking requests',
274
+ })
275
+ .option('slowest', {
276
+ alias: ['top', 'limit'],
277
+ type: 'number',
278
+ default: 20,
279
+ describe: 'Limit output to N slowest requests (default: 20)',
280
+ })
281
+ .option('initiator-chain', {
282
+ type: 'string',
283
+ describe: 'Trace the upstream request initiator chain matching a requestId or URL substring',
284
+ }), async (args) => {
285
+ const loaded = await loadTraceFromFile(args.file, args.traceEngine);
286
+ const rawRange = parseTimeRange(args.timeRange);
287
+ const timeRange = resolveTimeRange(rawRange, loaded.parsedTrace.data.Meta.traceBounds, args.absoluteTimestamps);
288
+ const result = extractNetwork(loaded, {
289
+ renderBlocking: args.renderBlocking,
290
+ slowest: args.slowest,
291
+ initiatorChain: args.initiatorChain,
292
+ timeRange,
293
+ });
294
+ console.log(formatResult(result, {
295
+ format: args.format,
296
+ type: 'network',
297
+ }));
298
+ })
299
+ .command('interactions <file>', 'User interaction latencies and target DOM elements', (y) => y.positional('file', {
300
+ describe: 'Path to trace file (.json or .json.gz)',
301
+ type: 'string',
302
+ demandOption: true,
303
+ }), async (args) => {
304
+ const loaded = await loadTraceFromFile(args.file, args.traceEngine);
305
+ const rawRange = parseTimeRange(args.timeRange);
306
+ const timeRange = resolveTimeRange(rawRange, loaded.parsedTrace.data.Meta.traceBounds, args.absoluteTimestamps);
307
+ const result = extractInteractions(loaded, { timeRange });
308
+ console.log(formatResult(result, {
309
+ format: args.format,
310
+ type: 'interactions',
311
+ }));
312
+ })
313
+ .command('shifts <file>', 'Layout shift clusters, scores, and culprit elements', (y) => y
314
+ .positional('file', {
315
+ describe: 'Path to trace file (.json or .json.gz)',
316
+ type: 'string',
317
+ demandOption: true,
318
+ })
319
+ .option('events', {
320
+ type: 'boolean',
321
+ default: false,
322
+ describe: 'Include individual shift events within each cluster',
323
+ }), async (args) => {
324
+ const loaded = await loadTraceFromFile(args.file, args.traceEngine);
325
+ const rawRange = parseTimeRange(args.timeRange);
326
+ const timeRange = resolveTimeRange(rawRange, loaded.parsedTrace.data.Meta.traceBounds, args.absoluteTimestamps);
327
+ const result = extractShifts(loaded, {
328
+ timeRange,
329
+ includeEvents: args.events,
330
+ });
331
+ console.log(formatResult(result, {
332
+ format: args.format,
333
+ type: 'shifts',
334
+ }));
335
+ })
336
+ .command('events <file>', 'Inspect and filter raw trace events by name, category, phase, or payload fields', (y) => y
337
+ .positional('file', {
338
+ describe: 'Path to trace file (.json or .json.gz)',
339
+ type: 'string',
340
+ demandOption: true,
341
+ })
342
+ .option('name', {
343
+ alias: ['event'],
344
+ type: 'string',
345
+ describe: 'Event name or comma-separated event names (e.g. "LayoutShift,navigationStart")',
346
+ })
347
+ .option('category', {
348
+ alias: ['cat'],
349
+ type: 'string',
350
+ describe: 'Filter by event category',
351
+ })
352
+ .option('phase', {
353
+ alias: ['ph'],
354
+ type: 'string',
355
+ describe: 'Filter by event phase (e.g. "I", "X", "B", "E")',
356
+ })
357
+ .option('fields', {
358
+ type: 'string',
359
+ describe: 'Comma-separated dot-notation field paths to extract (e.g. "name,ts,args.data.navigationId")',
360
+ })
361
+ .option('top', {
362
+ alias: ['limit'],
363
+ type: 'number',
364
+ default: 50,
365
+ describe: 'Maximum number of matching events to return (default: 50)',
366
+ }), async (args) => {
367
+ const loaded = await loadTraceFromFile(args.file, args.traceEngine);
368
+ const rawRange = parseTimeRange(args.timeRange);
369
+ const timeRange = resolveTimeRange(rawRange, loaded.parsedTrace.data.Meta.traceBounds, args.absoluteTimestamps);
370
+ const names = parseCommaList(args.name);
371
+ const fields = parseCommaList(args.fields);
372
+ const result = extractEvents(loaded, {
373
+ names,
374
+ category: args.category,
375
+ phase: args.phase,
376
+ fields,
377
+ timeRange,
378
+ limit: args.top,
379
+ });
380
+ console.log(formatResult(result, {
381
+ format: args.format,
382
+ type: 'events',
383
+ }));
384
+ })
385
+ .command(['navigations <file>', 'navs <file>'], 'List all navigations in the trace and their InsightSet mappings', (y) => y.positional('file', {
386
+ describe: 'Path to trace file (.json or .json.gz)',
387
+ type: 'string',
388
+ demandOption: true,
389
+ }), async (args) => {
390
+ const loaded = await loadTraceFromFile(args.file, args.traceEngine);
391
+ const result = extractNavigations(loaded);
392
+ console.log(formatResult(result, {
393
+ format: args.format,
394
+ type: 'navigations',
395
+ }));
396
+ })
397
+ .command('scan <targets..>', 'Scan multiple trace files or directories for events, categories, phases, or text patterns', (y) => y
398
+ .positional('targets', {
399
+ describe: 'Directory paths, glob patterns, or trace file paths (.json, .json.gz)',
400
+ type: 'string',
401
+ array: true,
402
+ demandOption: true,
403
+ })
404
+ .option('name', {
405
+ alias: ['event'],
406
+ type: 'string',
407
+ describe: 'Event name or comma-separated event names (e.g. "LayoutShift,RunTask")',
408
+ })
409
+ .option('category', {
410
+ alias: ['cat'],
411
+ type: 'string',
412
+ describe: 'Filter by event category',
413
+ })
414
+ .option('phase', {
415
+ alias: ['ph'],
416
+ type: 'string',
417
+ describe: 'Filter by event phase (e.g. "X", "I", "B", "E")',
418
+ })
419
+ .option('grep', {
420
+ alias: ['pattern'],
421
+ type: 'string',
422
+ describe: 'Raw text or substring to search for within trace events or payloads',
423
+ })
424
+ .option('fields', {
425
+ type: 'string',
426
+ describe: 'Comma-separated dot-notation property paths that must exist on matching events',
427
+ })
428
+ .option('top', {
429
+ alias: ['limit'],
430
+ type: 'number',
431
+ default: 50,
432
+ describe: 'Maximum number of matched files to return (default: 50)',
433
+ }), async (args) => {
434
+ const rawTargets = Array.isArray(args.targets)
435
+ ? args.targets
436
+ : [String(args.targets)];
437
+ const names = parseCommaList(args.name);
438
+ const fields = parseCommaList(args.fields);
439
+ const result = scanTraces({
440
+ targets: rawTargets,
441
+ names,
442
+ category: args.category,
443
+ phase: args.phase,
444
+ grep: args.grep,
445
+ fields,
446
+ limit: args.top,
447
+ });
448
+ console.log(formatResult(result, {
449
+ format: args.format,
450
+ type: 'scan',
451
+ }));
452
+ })
453
+ .demandCommand(1, 'Please specify a trace file or subcommand.')
454
+ .strict()
455
+ .fail((msg, err) => {
456
+ if (err) {
457
+ throw err;
458
+ }
459
+ if (msg) {
460
+ if (msg.startsWith('Unknown argument:')) {
461
+ const arg = msg.replace('Unknown argument:', '').trim();
462
+ if (arg.endsWith('.json') || arg.endsWith('.json.gz')) {
463
+ throw new Error(`Command accepts only a single trace file. Received multiple files. To search across multiple files or directories, use:\n trace-cli scan <dir-or-glob> [options]`);
464
+ }
465
+ }
466
+ throw new Error(msg);
467
+ }
468
+ throw new Error('Command execution failed.');
469
+ })
470
+ .help();
471
+ const userArgs = hideBin(argv);
472
+ if (userArgs.length === 0) {
473
+ cli.showHelp();
474
+ return;
475
+ }
476
+ await cli.parseAsync();
477
+ }
@@ -0,0 +1,13 @@
1
+ export type TraceEngine = typeof import('@paulirish/trace_engine');
2
+ /**
3
+ * Dynamically resolves and imports the DevTools Trace Engine module.
4
+ *
5
+ * Resolution priority:
6
+ * 1. Explicit path passed via `customPath` (e.g. `--trace-engine=<path>`)
7
+ * 2. Locally vendored engine bundle at `./vendor/trace_engine.js` (generated via `npm run bundle:engine`)
8
+ * 3. Default npm package distribution `@paulirish/trace_engine`
9
+ *
10
+ * @param customPath - Optional filesystem path to a custom Trace Engine bundle.
11
+ * @returns The imported TraceEngine module namespace.
12
+ */
13
+ export declare function loadTraceEngine(customPath?: string): Promise<TraceEngine>;
@@ -0,0 +1,51 @@
1
+ // Copyright 2026 Google LLC
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // https://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ let cachedEngine = null;
18
+ let cachedCustomPath;
19
+ /**
20
+ * Dynamically resolves and imports the DevTools Trace Engine module.
21
+ *
22
+ * Resolution priority:
23
+ * 1. Explicit path passed via `customPath` (e.g. `--trace-engine=<path>`)
24
+ * 2. Locally vendored engine bundle at `./vendor/trace_engine.js` (generated via `npm run bundle:engine`)
25
+ * 3. Default npm package distribution `@paulirish/trace_engine`
26
+ *
27
+ * @param customPath - Optional filesystem path to a custom Trace Engine bundle.
28
+ * @returns The imported TraceEngine module namespace.
29
+ */
30
+ export async function loadTraceEngine(customPath) {
31
+ if (cachedEngine && cachedCustomPath === customPath) {
32
+ return cachedEngine;
33
+ }
34
+ let engine;
35
+ if (customPath) {
36
+ const resolved = path.resolve(process.cwd(), customPath);
37
+ engine = (await import(pathToFileURL(resolved).href));
38
+ }
39
+ else {
40
+ const vendoredPath = path.resolve(import.meta.dirname, 'vendor/trace_engine.js');
41
+ if (fs.existsSync(vendoredPath)) {
42
+ engine = (await import(pathToFileURL(vendoredPath).href));
43
+ }
44
+ else {
45
+ engine = (await import('@paulirish/trace_engine'));
46
+ }
47
+ }
48
+ cachedEngine = engine;
49
+ cachedCustomPath = customPath;
50
+ return engine;
51
+ }
@@ -0,0 +1,12 @@
1
+ import type { TimeRange } from '../../utils/filter.js';
2
+ import type { LoadedTrace } from '../loader.js';
3
+ import type { DiffResult } from '../types.js';
4
+ export interface ExtractDiffOptions {
5
+ windowA: TimeRange;
6
+ windowB: TimeRange;
7
+ top?: number;
8
+ }
9
+ /**
10
+ * Compares CPU activity between two time windows within a single trace.
11
+ */
12
+ export declare function extractDiff(loadedTrace: LoadedTrace, options: ExtractDiffOptions): DiffResult;
@@ -0,0 +1,100 @@
1
+ // Copyright 2026 Google LLC
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // https://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ import { round } from '../../utils/units.js';
15
+ import { getMainRendererThread } from '../helpers.js';
16
+ /**
17
+ * Compares CPU activity between two time windows within a single trace.
18
+ */
19
+ export function extractDiff(loadedTrace, options) {
20
+ const { engine, parsedTrace } = loadedTrace;
21
+ const { windowA, windowB } = options;
22
+ const topCount = options.top ?? 20;
23
+ const result = {
24
+ windowA: {
25
+ startMs: windowA.startMs,
26
+ endMs: windowA.endMs,
27
+ durationMs: round(windowA.endMs - windowA.startMs),
28
+ },
29
+ windowB: {
30
+ startMs: windowB.startMs,
31
+ endMs: windowB.endMs,
32
+ durationMs: round(windowB.endMs - windowB.startMs),
33
+ },
34
+ entries: [],
35
+ };
36
+ const mainThread = getMainRendererThread(parsedTrace.data);
37
+ if (!mainThread?.entries?.length) {
38
+ return result;
39
+ }
40
+ const entriesParam = mainThread.entries;
41
+ function profileWindow(range) {
42
+ const bottomUp = new engine.Extras.TraceTree.BottomUpRootNode(entriesParam, {
43
+ textFilter: { accept: () => true },
44
+ filters: [],
45
+ startTime: engine.Types.Timing.Milli(range.startMs),
46
+ endTime: engine.Types.Timing.Milli(range.endMs),
47
+ });
48
+ const map = new Map();
49
+ let totalActive = 0;
50
+ const children = bottomUp.children();
51
+ for (const node of children.values()) {
52
+ if (node.selfTime <= 0)
53
+ continue;
54
+ // biome-ignore lint/suspicious/noExplicitAny: generic trace event
55
+ const frame = engine.Extras.TraceTree.eventStackFrame(node.event);
56
+ const fnName = frame?.functionName || node.event?.name || String(node.id) || 'Unknown';
57
+ if (fnName === '(root)' || fnName === '(idle)')
58
+ continue;
59
+ totalActive += node.selfTime;
60
+ const existing = map.get(fnName);
61
+ if (existing) {
62
+ existing.selfTimeMs += node.selfTime;
63
+ }
64
+ else {
65
+ map.set(fnName, {
66
+ name: fnName,
67
+ url: frame?.url,
68
+ selfTimeMs: node.selfTime,
69
+ });
70
+ }
71
+ }
72
+ return { map, totalActive };
73
+ }
74
+ const profA = profileWindow(windowA);
75
+ const profB = profileWindow(windowB);
76
+ const allNames = new Set([...profA.map.keys(), ...profB.map.keys()]);
77
+ const diffs = [];
78
+ for (const name of allNames) {
79
+ const a = profA.map.get(name);
80
+ const b = profB.map.get(name);
81
+ const msA = a ? round(a.selfTimeMs) : 0;
82
+ const msB = b ? round(b.selfTimeMs) : 0;
83
+ const pctA = profA.totalActive > 0 && a ? round((a.selfTimeMs / profA.totalActive) * 100, 1) : 0;
84
+ const pctB = profB.totalActive > 0 && b ? round((b.selfTimeMs / profB.totalActive) * 100, 1) : 0;
85
+ const deltaPct = round(pctB - pctA, 1);
86
+ diffs.push({
87
+ functionName: name,
88
+ url: b?.url || a?.url,
89
+ selfTimeMsA: msA,
90
+ selfTimePctA: pctA,
91
+ selfTimeMsB: msB,
92
+ selfTimePctB: pctB,
93
+ deltaPct,
94
+ });
95
+ }
96
+ // Sort by greatest absolute percentage shift
97
+ diffs.sort((x, y) => Math.abs(y.deltaPct) - Math.abs(x.deltaPct));
98
+ result.entries = diffs.slice(0, topCount);
99
+ return result;
100
+ }
@@ -0,0 +1,27 @@
1
+ import { type TimeRange } from '../../utils/filter.js';
2
+ import type { LoadedTrace } from '../loader.js';
3
+ import type { EventsResult } from '../types.js';
4
+ /**
5
+ * Configuration options for filtering and extracting raw trace events.
6
+ */
7
+ export interface ExtractEventsOptions {
8
+ /** Event names to match (exact match). Multiple names match as a union. */
9
+ names?: string[];
10
+ /** Exact trace event category (e.g. 'devtools.timeline', 'v8'). */
11
+ category?: string;
12
+ /** Chromium event phase code (e.g. 'X' for complete, 'I' for instant, 'B'/'E' for begin/end). */
13
+ phase?: string;
14
+ /** Dot-notation property paths to project into selectedFields (e.g. 'args.data.navigationId'). */
15
+ fields?: string[];
16
+ /** Time window in milliseconds (relative or absolute, matching trace bounds). */
17
+ timeRange?: TimeRange;
18
+ /** Maximum number of events to return in the events array. Default: 50. */
19
+ limit?: number;
20
+ }
21
+ /**
22
+ * Filters and extracts raw trace events matching query criteria.
23
+ *
24
+ * Normalizes event timestamps and durations to milliseconds.
25
+ * Returns the total number of matched events and a capped array of event items.
26
+ */
27
+ export declare function extractEvents(loadedTrace: LoadedTrace, options?: ExtractEventsOptions): EventsResult;