devtools-tracing 1.2.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ {
2
+ "version": "0.2.0",
3
+ "configurations": [
4
+ {
5
+ "type": "node",
6
+ "request": "launch",
7
+ "name": "Debug selector-stats",
8
+ "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
9
+ "args": ["cli.ts", "selector-stats", "${input:traceFile}"],
10
+ "console": "integratedTerminal"
11
+ },
12
+ {
13
+ "type": "node",
14
+ "request": "launch",
15
+ "name": "Debug inp",
16
+ "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
17
+ "args": ["cli.ts", "inp", "${input:traceFile}"],
18
+ "console": "integratedTerminal"
19
+ },
20
+ {
21
+ "type": "node",
22
+ "request": "launch",
23
+ "name": "Debug sourcemap",
24
+ "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
25
+ "args": ["cli.ts", "sourcemap", "${input:traceFile}"],
26
+ "console": "integratedTerminal"
27
+ },
28
+ {
29
+ "type": "node",
30
+ "request": "launch",
31
+ "name": "Debug stats",
32
+ "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/tsx",
33
+ "args": ["cli.ts", "stats", "${input:traceFile}"],
34
+ "console": "integratedTerminal"
35
+ }
36
+ ],
37
+ "inputs": [
38
+ {
39
+ "id": "traceFile",
40
+ "type": "promptString",
41
+ "description": "Path to trace file (.json or .json.gz)"
42
+ }
43
+ ]
44
+ }
package/README.md CHANGED
@@ -1,4 +1,70 @@
1
1
  # devtools-tracing
2
2
 
3
- Slightly trimmed down re-export of tracing related utilities from
4
- [devtools-frontend](https://github.com/ChromeDevTools/devtools-frontend).
3
+ Node.js library for programmatic analysis of Chrome DevTools performance traces. Re-exports the trace processing engine from [chrome-devtools-frontend](https://www.npmjs.com/package/chrome-devtools-frontend) and provides higher-level utilities for common tasks.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install devtools-tracing
9
+ ```
10
+
11
+ ## CLI
12
+
13
+ Analyze traces directly from the command line:
14
+
15
+ ```sh
16
+ npx devtools-tracing <command> <trace-file>
17
+ ```
18
+
19
+ Supports both `.json` and `.json.gz` trace files.
20
+
21
+ ### Commands
22
+
23
+ ```sh
24
+ # Top CSS selectors by elapsed time and match attempts, plus invalidation tracking
25
+ npx devtools-tracing selector-stats trace.json.gz
26
+
27
+ # INP (Interaction to Next Paint) breakdown
28
+ npx devtools-tracing inp trace.json.gz
29
+
30
+ # Timeline category statistics
31
+ npx devtools-tracing stats trace.json.gz
32
+
33
+ # Source map symbolication (writes a .symbolicated.json.gz alongside the input)
34
+ npx devtools-tracing sourcemap trace.json.gz
35
+ ```
36
+
37
+ See the [`commands/`](./commands) directory for full source.
38
+
39
+ ## Library usage
40
+
41
+ ```ts
42
+ import * as fs from 'node:fs';
43
+ import * as zlib from 'node:zlib';
44
+ import { initDevToolsTracing, Trace } from 'devtools-tracing';
45
+
46
+ // Must be called once before using the library.
47
+ initDevToolsTracing();
48
+
49
+ const raw = fs.readFileSync('trace.json.gz');
50
+ const traceData = JSON.parse(
51
+ zlib.gunzipSync(raw).toString(),
52
+ ) as Trace.Types.File.TraceFile;
53
+
54
+ const model = Trace.TraceModel.Model.createWithAllHandlers();
55
+ await model.parse(traceData.traceEvents, {
56
+ isCPUProfile: false,
57
+ isFreshRecording: false,
58
+ metadata: traceData.metadata,
59
+ });
60
+
61
+ const parsedTrace = model.parsedTrace(0);
62
+ ```
63
+
64
+ ## How it works
65
+
66
+ A [build script](./generate.ts) extracts trace-related code from the `chrome-devtools-frontend` npm package into `lib/`, resolving dependencies via AST analysis and stubbing browser APIs. The result is bundled into a single CommonJS file via esbuild.
67
+
68
+ ## License
69
+
70
+ BSD-3-Clause, matching the license of [chrome-devtools-frontend](https://github.com/ChromeDevTools/devtools-frontend/blob/main/LICENSE).
package/cli.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { run as cssSelectors } from './commands/selector-stats.js';
2
+ import { run as inp } from './commands/inp.js';
3
+ import { run as sourcemap } from './commands/sourcemap.js';
4
+ import { run as stats } from './commands/stats.js';
5
+
6
+ const commands: Record<string, (tracePath: string) => Promise<void>> = {
7
+ 'selector-stats': cssSelectors,
8
+ inp,
9
+ sourcemap,
10
+ stats,
11
+ };
12
+
13
+ function printUsage() {
14
+ console.log('Usage: devtools-tracing <command> <trace-file>');
15
+ console.log('\nCommands:');
16
+ console.log(' selector-stats Top CSS selectors from selector stats and invalidation tracking');
17
+ console.log(' inp Extract INP (Interaction to Next Paint) breakdown');
18
+ console.log(' sourcemap Symbolicate a trace using source maps');
19
+ console.log(' stats Generate timeline category statistics');
20
+ }
21
+
22
+ async function main() {
23
+ const [command, tracePath] = process.argv.slice(2);
24
+
25
+ if (!command || command === '--help' || command === '-h') {
26
+ printUsage();
27
+ process.exit(0);
28
+ }
29
+
30
+ const run = commands[command];
31
+ if (!run) {
32
+ console.error(`Unknown command: ${command}`);
33
+ printUsage();
34
+ process.exit(1);
35
+ }
36
+
37
+ if (!tracePath) {
38
+ console.error(`Missing trace file path.`);
39
+ console.error(`Usage: devtools-tracing ${command} <trace-file>`);
40
+ process.exit(1);
41
+ }
42
+
43
+ await run(tracePath);
44
+ }
45
+
46
+ main();
@@ -3,12 +3,7 @@ import * as zlib from 'node:zlib';
3
3
 
4
4
  import { initDevToolsTracing, Trace } from '../';
5
5
 
6
- async function main() {
7
- const tracePath = process.argv[2];
8
- if (!tracePath) {
9
- console.error('Usage: npm run examples:inp <path-to-trace-file>');
10
- process.exit(1);
11
- }
6
+ export async function run(tracePath: string) {
12
7
  initDevToolsTracing();
13
8
 
14
9
  const fileData = fs.readFileSync(tracePath);
@@ -30,5 +25,3 @@ async function main() {
30
25
  inp,
31
26
  });
32
27
  }
33
-
34
- main();
@@ -0,0 +1,145 @@
1
+ import * as fs from 'node:fs';
2
+ import * as zlib from 'node:zlib';
3
+
4
+ import { initDevToolsTracing, Trace } from '../';
5
+
6
+ const SelectorTimingsKey = Trace.Types.Events.SelectorTimingsKey;
7
+
8
+ export async function run(tracePath: string) {
9
+ initDevToolsTracing();
10
+
11
+ const fileData = fs.readFileSync(tracePath);
12
+ const decompressedData = tracePath.endsWith('.gz')
13
+ ? zlib.gunzipSync(fileData)
14
+ : fileData;
15
+ const traceData = JSON.parse(
16
+ decompressedData.toString(),
17
+ ) as Trace.Types.File.TraceFile;
18
+
19
+ const traceModel = Trace.TraceModel.Model.createWithAllHandlers({
20
+ debugMode: true,
21
+ enableAnimationsFrameHandler: false,
22
+ maxInvalidationEventsPerEvent: 20,
23
+ showAllEvents: false,
24
+ });
25
+ await traceModel.parse(traceData.traceEvents, {
26
+ isCPUProfile: false,
27
+ isFreshRecording: false,
28
+ metadata: traceData.metadata,
29
+ showAllEvents: false,
30
+ });
31
+ const parsedTrace = traceModel.parsedTrace(0)!;
32
+ const parsedTraceData = parsedTrace.data;
33
+
34
+ // --- Selector Stats ---
35
+ const selectorStatsData = parsedTraceData.SelectorStats;
36
+ const selectorMap = new Map<
37
+ string,
38
+ Trace.Types.Events.SelectorTiming
39
+ >();
40
+
41
+ for (const [, value] of selectorStatsData.dataForRecalcStyleEvent) {
42
+ for (const timing of value.timings) {
43
+ const key =
44
+ timing[SelectorTimingsKey.Selector] +
45
+ '_' +
46
+ timing[SelectorTimingsKey.StyleSheetId];
47
+ const existing = selectorMap.get(key);
48
+ if (existing) {
49
+ existing[SelectorTimingsKey.Elapsed] +=
50
+ timing[SelectorTimingsKey.Elapsed];
51
+ existing[SelectorTimingsKey.MatchAttempts] +=
52
+ timing[SelectorTimingsKey.MatchAttempts];
53
+ existing[SelectorTimingsKey.MatchCount] +=
54
+ timing[SelectorTimingsKey.MatchCount];
55
+ existing[SelectorTimingsKey.FastRejectCount] +=
56
+ timing[SelectorTimingsKey.FastRejectCount];
57
+ } else {
58
+ selectorMap.set(key, { ...timing });
59
+ }
60
+ }
61
+ }
62
+
63
+ const allTimings = [...selectorMap.values()];
64
+ const TOP_N = 15;
65
+
66
+ // Top selectors by elapsed time
67
+ const byElapsed = allTimings
68
+ .sort(
69
+ (a, b) =>
70
+ b[SelectorTimingsKey.Elapsed] - a[SelectorTimingsKey.Elapsed],
71
+ )
72
+ .slice(0, TOP_N);
73
+
74
+ console.log(`\n=== Top ${TOP_N} CSS Selectors by Elapsed Time ===\n`);
75
+ for (const t of byElapsed) {
76
+ const elapsedMs = (t[SelectorTimingsKey.Elapsed] / 1000).toFixed(2);
77
+ console.log(
78
+ ` ${elapsedMs}ms | attempts: ${t[SelectorTimingsKey.MatchAttempts]} | matches: ${t[SelectorTimingsKey.MatchCount]} | ${t[SelectorTimingsKey.Selector]}`,
79
+ );
80
+ }
81
+
82
+ // Top selectors by match attempts
83
+ const byAttempts = [...selectorMap.values()]
84
+ .sort(
85
+ (a, b) =>
86
+ b[SelectorTimingsKey.MatchAttempts] -
87
+ a[SelectorTimingsKey.MatchAttempts],
88
+ )
89
+ .slice(0, TOP_N);
90
+
91
+ console.log(`\n=== Top ${TOP_N} CSS Selectors by Match Attempts ===\n`);
92
+ for (const t of byAttempts) {
93
+ const elapsedMs = (t[SelectorTimingsKey.Elapsed] / 1000).toFixed(2);
94
+ console.log(
95
+ ` ${t[SelectorTimingsKey.MatchAttempts]} attempts | ${elapsedMs}ms | matches: ${t[SelectorTimingsKey.MatchCount]} | ${t[SelectorTimingsKey.Selector]}`,
96
+ );
97
+ }
98
+
99
+ // --- Invalidation Tracking ---
100
+ const invalidatedNodes = selectorStatsData.invalidatedNodeList;
101
+ console.log(
102
+ `\n=== Invalidation Tracking (${invalidatedNodes.length} invalidated nodes) ===\n`,
103
+ );
104
+
105
+ // Aggregate selectors that caused invalidations
106
+ const invalidationSelectorCounts = new Map<string, number>();
107
+ for (const node of invalidatedNodes) {
108
+ for (const sel of node.selectorList) {
109
+ const count = invalidationSelectorCounts.get(sel.selector) ?? 0;
110
+ invalidationSelectorCounts.set(sel.selector, count + 1);
111
+ }
112
+ }
113
+
114
+ const topInvalidationSelectors = [...invalidationSelectorCounts.entries()]
115
+ .sort((a, b) => b[1] - a[1])
116
+ .slice(0, TOP_N);
117
+
118
+ console.log(
119
+ ` Top ${TOP_N} selectors causing invalidations:\n`,
120
+ );
121
+ for (const [selector, count] of topInvalidationSelectors) {
122
+ console.log(` ${count}x | ${selector}`);
123
+ }
124
+
125
+ // Subtree vs single-node invalidations
126
+ const subtreeCount = invalidatedNodes.filter((n) => n.subtree).length;
127
+ console.log(
128
+ `\n Subtree invalidations: ${subtreeCount} / ${invalidatedNodes.length} total`,
129
+ );
130
+
131
+ // --- Totals ---
132
+ let totalElapsedUs = 0;
133
+ let totalMatchAttempts = 0;
134
+ let totalMatchCount = 0;
135
+ for (const t of allTimings) {
136
+ totalElapsedUs += t[SelectorTimingsKey.Elapsed];
137
+ totalMatchAttempts += t[SelectorTimingsKey.MatchAttempts];
138
+ totalMatchCount += t[SelectorTimingsKey.MatchCount];
139
+ }
140
+ console.log(`\n=== Totals ===\n`);
141
+ console.log(` Unique selectors: ${allTimings.length}`);
142
+ console.log(` Total elapsed: ${(totalElapsedUs / 1000).toFixed(2)}ms`);
143
+ console.log(` Total match attempts: ${totalMatchAttempts}`);
144
+ console.log(` Total match count: ${totalMatchCount}`);
145
+ }
@@ -0,0 +1,114 @@
1
+ import * as fs from 'node:fs';
2
+ import * as zlib from 'node:zlib';
3
+
4
+ import {
5
+ initDevToolsTracing,
6
+ createSourceMapResolver,
7
+ symbolicateTrace,
8
+ Trace,
9
+ } from '../';
10
+
11
+ export async function run(tracePath: string) {
12
+ initDevToolsTracing();
13
+
14
+ const fileData = fs.readFileSync(tracePath);
15
+ const decompressedData = tracePath.endsWith('.gz')
16
+ ? zlib.gunzipSync(fileData)
17
+ : fileData;
18
+ const traceData = JSON.parse(
19
+ decompressedData.toString(),
20
+ ) as Trace.Types.File.TraceFile;
21
+
22
+ const traceModel = Trace.TraceModel.Model.createWithAllHandlers();
23
+ const resolveSourceMap = createSourceMapResolver({
24
+ fetch: verboseFetch,
25
+ });
26
+
27
+ await traceModel.parse(traceData.traceEvents, {
28
+ isCPUProfile: false,
29
+ isFreshRecording: false,
30
+ metadata: traceData.metadata,
31
+ showAllEvents: false,
32
+ resolveSourceMap,
33
+ });
34
+
35
+ const parsedTrace = traceModel.parsedTrace(0)!;
36
+ const parsedTraceData = parsedTrace.data;
37
+ const scripts = parsedTraceData.Scripts.scripts;
38
+
39
+ const metadataSourceMaps = traceData.metadata?.sourceMaps?.length ?? 0;
40
+ console.log(`Found ${scripts.length} scripts in trace`);
41
+ console.log(`Source maps in trace metadata: ${metadataSourceMaps}\n`);
42
+
43
+ // Diagnostic: show script properties to explain resolution results.
44
+ let withUrl = 0;
45
+ let withFrame = 0;
46
+ let withSourceMapUrl = 0;
47
+ let withElided = 0;
48
+ for (const script of scripts) {
49
+ if (script.url) withUrl++;
50
+ if (script.frame) withFrame++;
51
+ if (script.sourceMapUrl) withSourceMapUrl++;
52
+ if (script.sourceMapUrlElided) withElided++;
53
+ }
54
+ console.log('Script breakdown:');
55
+ console.log(` with url: ${withUrl}`);
56
+ console.log(` with frame: ${withFrame}`);
57
+ console.log(` with sourceMapUrl: ${withSourceMapUrl}`);
58
+ console.log(` with sourceMapUrlElided: ${withElided}`);
59
+ console.log(` (resolution requires url + frame + sourceMapUrl/elided)\n`);
60
+
61
+ let resolvedCount = 0;
62
+ for (const script of scripts) {
63
+ if (!script.sourceMap) {
64
+ continue;
65
+ }
66
+ resolvedCount++;
67
+
68
+ const sourceMap = script.sourceMap;
69
+ const sourceURLs = sourceMap.sourceURLs();
70
+
71
+ console.log(
72
+ ` ${script.url || script.scriptId} -> ${sourceURLs.length} sources, ${sourceMap.mappings().length} mappings`,
73
+ );
74
+ }
75
+
76
+ console.log(
77
+ `Resolved source maps for ${resolvedCount} of ${scripts.length} scripts\n`,
78
+ );
79
+
80
+ // Symbolicate the raw trace events in-place using resolved source maps.
81
+ const result = symbolicateTrace(traceData.traceEvents, scripts);
82
+ console.log(
83
+ `Symbolicated ${result.symbolicatedFrames} frames across ${result.symbolicatedEvents} events`,
84
+ );
85
+
86
+ // Write the symbolicated trace to a new file.
87
+ const outPath = tracePath.replace(/(\.(json|json\.gz))$/i, '.symbolicated$1');
88
+ if (outPath === tracePath) {
89
+ console.error(
90
+ 'Could not determine output path (expected .json or .json.gz extension)',
91
+ );
92
+ process.exit(1);
93
+ }
94
+ const outputJson = JSON.stringify(traceData);
95
+ if (outPath.endsWith('.gz')) {
96
+ fs.writeFileSync(outPath, zlib.gzipSync(outputJson));
97
+ } else {
98
+ fs.writeFileSync(outPath, outputJson);
99
+ }
100
+ console.log(`Wrote symbolicated trace to ${outPath}`);
101
+ }
102
+
103
+ async function verboseFetch(url: string): Promise<Response> {
104
+ console.log(`[sourcemap] fetching ${url}`);
105
+ const response = await fetch(url);
106
+ if (!response.ok) {
107
+ console.warn(
108
+ `[sourcemap] ${url} -> ${response.status} ${response.statusText}`,
109
+ );
110
+ } else {
111
+ console.log(`[sourcemap] ${url} -> ok`);
112
+ }
113
+ return response;
114
+ }
@@ -8,12 +8,7 @@ import {
8
8
  Trace,
9
9
  } from '../';
10
10
 
11
- async function main() {
12
- const tracePath = process.argv[2];
13
- if (!tracePath) {
14
- console.error('Usage: npm run examples:stats <path-to-trace-file>');
15
- process.exit(1);
16
- }
11
+ export async function run(tracePath: string) {
17
12
  initDevToolsTracing();
18
13
 
19
14
  const fileData = fs.readFileSync(tracePath);
@@ -27,7 +22,6 @@ async function main() {
27
22
  const traceModel = Trace.TraceModel.Model.createWithAllHandlers({
28
23
  debugMode: true,
29
24
  enableAnimationsFrameHandler: false,
30
- // includeRuntimeCallStats: false,
31
25
  maxInvalidationEventsPerEvent: 20,
32
26
  showAllEvents: false,
33
27
  });
@@ -62,5 +56,3 @@ async function main() {
62
56
 
63
57
  console.log(stats);
64
58
  }
65
-
66
- main();