trace-engine-cli 0.0.0-stage → 0.0.1
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/LICENSE +202 -0
- package/README.md +360 -2
- package/dist/bin/trace-cli.d.ts +2 -0
- package/dist/bin/trace-cli.js +19 -0
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +477 -0
- package/dist/src/engine/adapter.d.ts +13 -0
- package/dist/src/engine/adapter.js +51 -0
- package/dist/src/engine/extractors/diff.d.ts +12 -0
- package/dist/src/engine/extractors/diff.js +100 -0
- package/dist/src/engine/extractors/events.d.ts +27 -0
- package/dist/src/engine/extractors/events.js +84 -0
- package/dist/src/engine/extractors/find.d.ts +12 -0
- package/dist/src/engine/extractors/find.js +100 -0
- package/dist/src/engine/extractors/insights.d.ts +15 -0
- package/dist/src/engine/extractors/insights.js +153 -0
- package/dist/src/engine/extractors/interactions.d.ts +7 -0
- package/dist/src/engine/extractors/interactions.js +55 -0
- package/dist/src/engine/extractors/navigations.d.ts +10 -0
- package/dist/src/engine/extractors/navigations.js +82 -0
- package/dist/src/engine/extractors/network.d.ts +10 -0
- package/dist/src/engine/extractors/network.js +105 -0
- package/dist/src/engine/extractors/overview.d.ts +11 -0
- package/dist/src/engine/extractors/overview.js +232 -0
- package/dist/src/engine/extractors/profile.d.ts +12 -0
- package/dist/src/engine/extractors/profile.js +83 -0
- package/dist/src/engine/extractors/scan.d.ts +37 -0
- package/dist/src/engine/extractors/scan.js +218 -0
- package/dist/src/engine/extractors/scheduling.d.ts +10 -0
- package/dist/src/engine/extractors/scheduling.js +89 -0
- package/dist/src/engine/extractors/shifts.d.ts +11 -0
- package/dist/src/engine/extractors/shifts.js +118 -0
- package/dist/src/engine/extractors/tasks.d.ts +13 -0
- package/dist/src/engine/extractors/tasks.js +156 -0
- package/dist/src/engine/helpers.d.ts +157 -0
- package/dist/src/engine/helpers.js +95 -0
- package/dist/src/engine/loader.d.ts +34 -0
- package/dist/src/engine/loader.js +142 -0
- package/dist/src/engine/types.d.ts +452 -0
- package/dist/src/engine/types.js +14 -0
- package/dist/src/formatters/index.d.ts +6 -0
- package/dist/src/formatters/index.js +66 -0
- package/dist/src/formatters/markdown.d.ts +15 -0
- package/dist/src/formatters/markdown.js +474 -0
- package/dist/src/formatters/toon.d.ts +1 -0
- package/dist/src/formatters/toon.js +17 -0
- package/dist/src/utils/filter.d.ts +47 -0
- package/dist/src/utils/filter.js +136 -0
- package/dist/src/utils/units.d.ts +24 -0
- package/dist/src/utils/units.js +57 -0
- package/package.json +45 -4
|
@@ -0,0 +1,232 @@
|
|
|
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 { microToMilli, round } from '../../utils/units.js';
|
|
15
|
+
import { getMainRendererThread, getPrimaryInsightSet } from '../helpers.js';
|
|
16
|
+
function rateFcp(fcpMs) {
|
|
17
|
+
if (fcpMs <= 1800)
|
|
18
|
+
return 'good';
|
|
19
|
+
if (fcpMs <= 3000)
|
|
20
|
+
return 'needs-improvement';
|
|
21
|
+
return 'poor';
|
|
22
|
+
}
|
|
23
|
+
function rateLcp(lcpMs) {
|
|
24
|
+
if (lcpMs <= 2500)
|
|
25
|
+
return 'good';
|
|
26
|
+
if (lcpMs <= 4000)
|
|
27
|
+
return 'needs-improvement';
|
|
28
|
+
return 'poor';
|
|
29
|
+
}
|
|
30
|
+
function rateCls(cls) {
|
|
31
|
+
if (cls <= 0.1)
|
|
32
|
+
return 'good';
|
|
33
|
+
if (cls <= 0.25)
|
|
34
|
+
return 'needs-improvement';
|
|
35
|
+
return 'poor';
|
|
36
|
+
}
|
|
37
|
+
function rateInp(inpMs) {
|
|
38
|
+
if (inpMs <= 200)
|
|
39
|
+
return 'good';
|
|
40
|
+
if (inpMs <= 500)
|
|
41
|
+
return 'needs-improvement';
|
|
42
|
+
return 'poor';
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Extracts high-level trace metadata, Core Web Vitals, and main thread category distribution.
|
|
46
|
+
* All duration and timestamp properties are normalized to milliseconds (ms).
|
|
47
|
+
*/
|
|
48
|
+
export function extractOverview(loadedTrace, options) {
|
|
49
|
+
const { engine, parsedTrace } = loadedTrace;
|
|
50
|
+
const data = parsedTrace.data;
|
|
51
|
+
const timeRange = options?.timeRange;
|
|
52
|
+
// 1. Trace Bounds & Duration
|
|
53
|
+
const traceBounds = data.Meta.traceBounds;
|
|
54
|
+
const minMs = timeRange ? timeRange.startMs : round(microToMilli(traceBounds.min));
|
|
55
|
+
const maxMs = timeRange ? timeRange.endMs : round(microToMilli(traceBounds.max));
|
|
56
|
+
const durationMs = round(maxMs - minMs);
|
|
57
|
+
// 2. Main URL & Navigation Start
|
|
58
|
+
const mainUrl = data.Meta.mainFrameURL || null;
|
|
59
|
+
const mainNavs = (data.Meta.mainFrameNavigations || []);
|
|
60
|
+
const allNavs = Array.from((data.Meta.navigationsByNavigationId?.values() || []));
|
|
61
|
+
const firstNav = mainNavs[0] || allNavs[0];
|
|
62
|
+
const navigationStartMs = firstNav ? round(microToMilli(firstNav.ts)) : null;
|
|
63
|
+
// 3. Core Web Vitals
|
|
64
|
+
const mainFrameId = data.Meta.mainFrameId;
|
|
65
|
+
const pageMetrics = data.PageLoadMetrics?.metricScoresByFrameId?.get(mainFrameId);
|
|
66
|
+
let fcp = null;
|
|
67
|
+
let lcp = null;
|
|
68
|
+
if (pageMetrics) {
|
|
69
|
+
for (const navMetrics of pageMetrics.values()) {
|
|
70
|
+
const fcpMetric = navMetrics.get('FCP');
|
|
71
|
+
if (fcpMetric && fcpMetric.timing !== undefined && !fcp) {
|
|
72
|
+
const valMs = round(microToMilli(fcpMetric.timing));
|
|
73
|
+
fcp = {
|
|
74
|
+
valueMs: valMs,
|
|
75
|
+
rating: fcpMetric.classification || rateFcp(valMs),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const lcpMetric = navMetrics.get('LCP');
|
|
79
|
+
if (lcpMetric && lcpMetric.timing !== undefined && !lcp) {
|
|
80
|
+
const valMs = round(microToMilli(lcpMetric.timing));
|
|
81
|
+
lcp = {
|
|
82
|
+
valueMs: valMs,
|
|
83
|
+
rating: lcpMetric.classification || rateLcp(valMs),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Check insights for LCP URL / Element if available
|
|
89
|
+
const insightSet = getPrimaryInsightSet(parsedTrace);
|
|
90
|
+
if (insightSet?.LCPBreakdown) {
|
|
91
|
+
const lcpInsight = insightSet.LCPBreakdown;
|
|
92
|
+
if (lcpInsight.lcpMs !== undefined && !lcp) {
|
|
93
|
+
const valMs = round(lcpInsight.lcpMs);
|
|
94
|
+
lcp = {
|
|
95
|
+
valueMs: valMs,
|
|
96
|
+
rating: rateLcp(valMs),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (lcp) {
|
|
100
|
+
if (lcpInsight.lcpRequest?.args?.data?.url) {
|
|
101
|
+
lcp.url = lcpInsight.lcpRequest.args.data.url;
|
|
102
|
+
}
|
|
103
|
+
if (lcpInsight.lcpEvent?.args?.data?.nodeName) {
|
|
104
|
+
lcp.element = lcpInsight.lcpEvent.args.data.nodeName;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// CLS
|
|
109
|
+
let cls = null;
|
|
110
|
+
const layoutShifts = data.LayoutShifts;
|
|
111
|
+
if (layoutShifts) {
|
|
112
|
+
const score = layoutShifts.sessionMaxScore ?? 0;
|
|
113
|
+
const worstCluster = layoutShifts.clusters?.[0]?.clusterCumulativeScore;
|
|
114
|
+
cls = {
|
|
115
|
+
value: round(score, 4),
|
|
116
|
+
rating: rateCls(score),
|
|
117
|
+
worstClusterScore: worstCluster !== undefined ? round(worstCluster, 4) : undefined,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// INP
|
|
121
|
+
let inp = null;
|
|
122
|
+
const userInteractions = data.UserInteractions;
|
|
123
|
+
const longestInteraction = userInteractions?.longestInteractionEvent;
|
|
124
|
+
if (longestInteraction) {
|
|
125
|
+
const durMs = round(microToMilli(longestInteraction.dur));
|
|
126
|
+
inp = {
|
|
127
|
+
valueMs: durMs,
|
|
128
|
+
rating: rateInp(durMs),
|
|
129
|
+
interactionType: longestInteraction.type,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
else if (insightSet?.INPBreakdown?.longestInteractionEvent) {
|
|
133
|
+
const ev = insightSet.INPBreakdown.longestInteractionEvent;
|
|
134
|
+
const durMs = round(microToMilli(ev.dur));
|
|
135
|
+
inp = {
|
|
136
|
+
valueMs: durMs,
|
|
137
|
+
rating: rateInp(durMs),
|
|
138
|
+
interactionType: ev.type,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// 4. Main Thread Breakdown (Derived from TraceTree self-times to eliminate double-counting)
|
|
142
|
+
const breakdown = {
|
|
143
|
+
scriptingMs: 0,
|
|
144
|
+
layoutMs: 0,
|
|
145
|
+
renderingMs: 0,
|
|
146
|
+
gcMs: 0,
|
|
147
|
+
systemMs: 0,
|
|
148
|
+
idleMs: 0,
|
|
149
|
+
totalMs: 0,
|
|
150
|
+
};
|
|
151
|
+
const mainThread = getMainRendererThread(data);
|
|
152
|
+
if (mainThread?.entries && mainThread.entries.length > 0) {
|
|
153
|
+
const bottomUp = new engine.Extras.TraceTree.BottomUpRootNode(mainThread.entries, {
|
|
154
|
+
textFilter: { accept: () => true },
|
|
155
|
+
filters: [],
|
|
156
|
+
startTime: engine.Types.Timing.Milli(minMs),
|
|
157
|
+
endTime: engine.Types.Timing.Milli(maxMs),
|
|
158
|
+
});
|
|
159
|
+
const children = bottomUp.children();
|
|
160
|
+
for (const node of children.values()) {
|
|
161
|
+
if (node.selfTime <= 0)
|
|
162
|
+
continue;
|
|
163
|
+
const name = node.event?.name || String(node.id) || '';
|
|
164
|
+
if (name === 'MajorGC' ||
|
|
165
|
+
name === 'MinorGC' ||
|
|
166
|
+
name === 'GCEvent' ||
|
|
167
|
+
name.startsWith('v8.gc')) {
|
|
168
|
+
breakdown.gcMs += node.selfTime;
|
|
169
|
+
}
|
|
170
|
+
else if (name === 'Layout' || name === 'UpdateLayoutTree' || name === 'InvalidateLayout') {
|
|
171
|
+
breakdown.layoutMs += node.selfTime;
|
|
172
|
+
}
|
|
173
|
+
else if (name === 'UpdateLayerTree' ||
|
|
174
|
+
name === 'Paint' ||
|
|
175
|
+
name === 'CompositeLayers' ||
|
|
176
|
+
name === 'RasterTask' ||
|
|
177
|
+
name === 'DecodeImage') {
|
|
178
|
+
breakdown.renderingMs += node.selfTime;
|
|
179
|
+
}
|
|
180
|
+
else if (name.startsWith('f:') ||
|
|
181
|
+
name === 'EvaluateScript' ||
|
|
182
|
+
name === 'FunctionCall' ||
|
|
183
|
+
name === 'v8.compile' ||
|
|
184
|
+
name === 'v8.run' ||
|
|
185
|
+
name === 'TimerFire' ||
|
|
186
|
+
name === 'FireAnimationFrame' ||
|
|
187
|
+
name === 'EventDispatch') {
|
|
188
|
+
breakdown.scriptingMs += node.selfTime;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
breakdown.systemMs += node.selfTime;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const activeWork = breakdown.scriptingMs +
|
|
196
|
+
breakdown.layoutMs +
|
|
197
|
+
breakdown.renderingMs +
|
|
198
|
+
breakdown.gcMs +
|
|
199
|
+
breakdown.systemMs;
|
|
200
|
+
breakdown.idleMs = round(Math.max(0, durationMs - activeWork));
|
|
201
|
+
breakdown.scriptingMs = round(breakdown.scriptingMs);
|
|
202
|
+
breakdown.layoutMs = round(breakdown.layoutMs);
|
|
203
|
+
breakdown.renderingMs = round(breakdown.renderingMs);
|
|
204
|
+
breakdown.gcMs = round(breakdown.gcMs);
|
|
205
|
+
breakdown.systemMs = round(breakdown.systemMs);
|
|
206
|
+
breakdown.totalMs = durationMs;
|
|
207
|
+
// 5. Warnings
|
|
208
|
+
const warnings = [];
|
|
209
|
+
if (fcp && fcp.rating === 'poor') {
|
|
210
|
+
warnings.push(`Poor First Contentful Paint: ${fcp.valueMs}ms (threshold: 1800ms)`);
|
|
211
|
+
}
|
|
212
|
+
if (lcp && lcp.rating === 'poor') {
|
|
213
|
+
warnings.push(`Poor Largest Contentful Paint: ${lcp.valueMs}ms (threshold: 2500ms)`);
|
|
214
|
+
}
|
|
215
|
+
if (cls && cls.rating === 'poor') {
|
|
216
|
+
warnings.push(`Poor Cumulative Layout Shift: ${cls.value} (threshold: 0.1)`);
|
|
217
|
+
}
|
|
218
|
+
if (inp && inp.rating === 'poor') {
|
|
219
|
+
warnings.push(`Poor Interaction to Next Paint: ${inp.valueMs}ms (threshold: 200ms)`);
|
|
220
|
+
}
|
|
221
|
+
if ((insightSet?.RenderBlocking?.renderBlockingRequests?.length ?? 0) > 0) {
|
|
222
|
+
warnings.push(`${insightSet?.RenderBlocking?.renderBlockingRequests?.length} render-blocking resource(s) detected.`);
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
bounds: { minMs, maxMs, durationMs },
|
|
226
|
+
mainUrl,
|
|
227
|
+
navigationStartMs,
|
|
228
|
+
coreWebVitals: { fcp, lcp, cls, inp },
|
|
229
|
+
mainThreadBreakdown: breakdown,
|
|
230
|
+
warnings,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TimeRange } from '../../utils/filter.js';
|
|
2
|
+
import type { LoadedTrace } from '../loader.js';
|
|
3
|
+
import type { ProfileResult } from '../types.js';
|
|
4
|
+
export interface ExtractProfileOptions {
|
|
5
|
+
timeRange?: TimeRange;
|
|
6
|
+
bucketMs?: number;
|
|
7
|
+
top?: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Generates time-bucketed CPU profiles across a time range.
|
|
11
|
+
*/
|
|
12
|
+
export declare function extractProfile(loadedTrace: LoadedTrace, options?: ExtractProfileOptions): ProfileResult;
|
|
@@ -0,0 +1,83 @@
|
|
|
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 { microToMilli, round } from '../../utils/units.js';
|
|
15
|
+
import { getMainRendererThread } from '../helpers.js';
|
|
16
|
+
/**
|
|
17
|
+
* Generates time-bucketed CPU profiles across a time range.
|
|
18
|
+
*/
|
|
19
|
+
export function extractProfile(loadedTrace, options) {
|
|
20
|
+
const { engine, parsedTrace } = loadedTrace;
|
|
21
|
+
const traceBounds = parsedTrace.data.Meta.traceBounds;
|
|
22
|
+
const startMs = options?.timeRange
|
|
23
|
+
? options.timeRange.startMs
|
|
24
|
+
: round(microToMilli(traceBounds.min));
|
|
25
|
+
const endMs = options?.timeRange ? options.timeRange.endMs : round(microToMilli(traceBounds.max));
|
|
26
|
+
const totalRangeMs = Math.max(1, endMs - startMs);
|
|
27
|
+
const bucketSizeMs = options?.bucketMs ?? Math.max(10, round(totalRangeMs / 20));
|
|
28
|
+
const topCount = options?.top ?? 3;
|
|
29
|
+
const result = {
|
|
30
|
+
timeRange: { startMs, endMs },
|
|
31
|
+
bucketSizeMs,
|
|
32
|
+
buckets: [],
|
|
33
|
+
};
|
|
34
|
+
const mainThread = getMainRendererThread(parsedTrace.data);
|
|
35
|
+
if (!mainThread?.entries?.length) {
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
const entriesParam = mainThread.entries;
|
|
39
|
+
let currentStart = startMs;
|
|
40
|
+
while (currentStart < endMs) {
|
|
41
|
+
const currentEnd = Math.min(endMs, round(currentStart + bucketSizeMs));
|
|
42
|
+
if (currentEnd <= currentStart)
|
|
43
|
+
break;
|
|
44
|
+
const bottomUp = new engine.Extras.TraceTree.BottomUpRootNode(entriesParam, {
|
|
45
|
+
textFilter: { accept: () => true },
|
|
46
|
+
filters: [],
|
|
47
|
+
startTime: engine.Types.Timing.Milli(currentStart),
|
|
48
|
+
endTime: engine.Types.Timing.Milli(currentEnd),
|
|
49
|
+
});
|
|
50
|
+
const funcList = [];
|
|
51
|
+
let totalActiveMs = 0;
|
|
52
|
+
const children = bottomUp.children();
|
|
53
|
+
for (const node of children.values()) {
|
|
54
|
+
if (node.selfTime <= 0)
|
|
55
|
+
continue;
|
|
56
|
+
// biome-ignore lint/suspicious/noExplicitAny: generic trace event
|
|
57
|
+
const frame = engine.Extras.TraceTree.eventStackFrame(node.event);
|
|
58
|
+
const fnName = frame?.functionName || node.event?.name || String(node.id) || 'Unknown';
|
|
59
|
+
// Exclude generic root markers from active work
|
|
60
|
+
if (fnName === '(root)' || fnName === '(idle)')
|
|
61
|
+
continue;
|
|
62
|
+
totalActiveMs += node.selfTime;
|
|
63
|
+
funcList.push({
|
|
64
|
+
name: fnName,
|
|
65
|
+
selfTimeMs: round(node.selfTime),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
funcList.sort((a, b) => b.selfTimeMs - a.selfTimeMs);
|
|
69
|
+
const topFunctions = funcList.slice(0, topCount).map((f) => ({
|
|
70
|
+
name: f.name,
|
|
71
|
+
selfTimeMs: f.selfTimeMs,
|
|
72
|
+
selfTimePct: totalActiveMs > 0 ? round((f.selfTimeMs / totalActiveMs) * 100, 1) : 0,
|
|
73
|
+
}));
|
|
74
|
+
result.buckets.push({
|
|
75
|
+
startMs: round(currentStart),
|
|
76
|
+
endMs: round(currentEnd),
|
|
77
|
+
totalActiveMs: round(totalActiveMs),
|
|
78
|
+
topFunctions,
|
|
79
|
+
});
|
|
80
|
+
currentStart = currentEnd;
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ScanResult } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Options for scanning multiple trace files or directories.
|
|
4
|
+
*/
|
|
5
|
+
export interface ScanOptions {
|
|
6
|
+
/** Target directories, glob patterns, or file paths (.json, .json.gz). */
|
|
7
|
+
targets: string[];
|
|
8
|
+
/** Exact event name or list of event names to match. */
|
|
9
|
+
names?: string[];
|
|
10
|
+
/** Exact event category to match. */
|
|
11
|
+
category?: string;
|
|
12
|
+
/** Exact event phase to match (e.g. 'X', 'I', 'B', 'E'). */
|
|
13
|
+
phase?: string;
|
|
14
|
+
/** Substring to match across event names, categories, or payload arguments. */
|
|
15
|
+
grep?: string;
|
|
16
|
+
/** Dot-notation property paths that must exist on matching events. */
|
|
17
|
+
fields?: string[];
|
|
18
|
+
/** Maximum number of matching files to return in the result. Default: 50. */
|
|
19
|
+
limit?: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolves a list of directory paths, glob patterns, or file paths into a deduplicated list of trace files.
|
|
23
|
+
*
|
|
24
|
+
* @throws {Error} When a literal target path does not exist.
|
|
25
|
+
* @throws {Error} When no trace files (.json, .json.gz) are matched across all targets.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveScanTargets(targets: string[]): string[];
|
|
28
|
+
/**
|
|
29
|
+
* Scans multiple trace files or directories for events matching query criteria.
|
|
30
|
+
*
|
|
31
|
+
* Uses fast string pre-filtering before JSON parsing to achieve high scanning throughput
|
|
32
|
+
* across hundreds of fixtures without running heavyweight DevTools Trace Engine pipelines.
|
|
33
|
+
*
|
|
34
|
+
* @throws {Error} When no search filter options are provided.
|
|
35
|
+
* @throws {Error} When resolveScanTargets fails to match trace files.
|
|
36
|
+
*/
|
|
37
|
+
export declare function scanTraces(options: ScanOptions): ScanResult;
|
|
@@ -0,0 +1,218 @@
|
|
|
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 { microToMilli } from '../../utils/units.js';
|
|
17
|
+
import { getPropertyByPath } from '../helpers.js';
|
|
18
|
+
import { decompressTraceBuffer, parseTraceJson } from '../loader.js';
|
|
19
|
+
/**
|
|
20
|
+
* Resolves a list of directory paths, glob patterns, or file paths into a deduplicated list of trace files.
|
|
21
|
+
*
|
|
22
|
+
* @throws {Error} When a literal target path does not exist.
|
|
23
|
+
* @throws {Error} When no trace files (.json, .json.gz) are matched across all targets.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveScanTargets(targets) {
|
|
26
|
+
const resolvedFiles = new Set();
|
|
27
|
+
for (const target of targets) {
|
|
28
|
+
const hasGlobWildcards = /[*?[\]]/.test(target);
|
|
29
|
+
if (hasGlobWildcards) {
|
|
30
|
+
const globbed = fs.globSync(target);
|
|
31
|
+
for (const file of globbed) {
|
|
32
|
+
if (file.endsWith('.json') || file.endsWith('.json.gz')) {
|
|
33
|
+
resolvedFiles.add(path.resolve(file));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
let stat;
|
|
39
|
+
try {
|
|
40
|
+
stat = fs.statSync(target);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (err.code === 'ENOENT') {
|
|
44
|
+
throw new Error(`Target path not found: ${target}`);
|
|
45
|
+
}
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
if (stat.isDirectory()) {
|
|
49
|
+
const dirents = fs.readdirSync(target, { recursive: true, withFileTypes: true });
|
|
50
|
+
for (const dirent of dirents) {
|
|
51
|
+
if (dirent.isFile()) {
|
|
52
|
+
const name = dirent.name;
|
|
53
|
+
if (name.endsWith('.json') || name.endsWith('.json.gz')) {
|
|
54
|
+
const dir = dirent.parentPath ?? dirent.path ?? target;
|
|
55
|
+
resolvedFiles.add(path.resolve(dir, name));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (stat.isFile()) {
|
|
61
|
+
if (target.endsWith('.json') || target.endsWith('.json.gz')) {
|
|
62
|
+
resolvedFiles.add(path.resolve(target));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const sorted = Array.from(resolvedFiles).sort();
|
|
67
|
+
if (sorted.length === 0) {
|
|
68
|
+
throw new Error(`No trace files (.json, .json.gz) found matching targets: ${targets.join(', ')}`);
|
|
69
|
+
}
|
|
70
|
+
return sorted;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Scans multiple trace files or directories for events matching query criteria.
|
|
74
|
+
*
|
|
75
|
+
* Uses fast string pre-filtering before JSON parsing to achieve high scanning throughput
|
|
76
|
+
* across hundreds of fixtures without running heavyweight DevTools Trace Engine pipelines.
|
|
77
|
+
*
|
|
78
|
+
* @throws {Error} When no search filter options are provided.
|
|
79
|
+
* @throws {Error} When resolveScanTargets fails to match trace files.
|
|
80
|
+
*/
|
|
81
|
+
export function scanTraces(options) {
|
|
82
|
+
const { targets, names, category, phase, grep, fields, limit = 50 } = options;
|
|
83
|
+
if (!names?.length && !category && !phase && !grep && !fields?.length) {
|
|
84
|
+
throw new Error('At least one search filter must be specified (--name, --category, --phase, --grep, or --fields).');
|
|
85
|
+
}
|
|
86
|
+
const files = resolveScanTargets(targets);
|
|
87
|
+
const namesSet = names && names.length > 0 ? new Set(names) : null;
|
|
88
|
+
// Extract final property identifier from dot-notation path (e.g. 'args.data.id' -> 'id') for substring pre-filtering.
|
|
89
|
+
const leafFields = fields && fields.length > 0 ? fields.map((f) => f.split('.').pop() ?? f) : null;
|
|
90
|
+
const matchedFiles = [];
|
|
91
|
+
let totalEventsMatched = 0;
|
|
92
|
+
for (const filePath of files) {
|
|
93
|
+
let rawBuffer;
|
|
94
|
+
try {
|
|
95
|
+
rawBuffer = fs.readFileSync(filePath);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
let jsonContent;
|
|
101
|
+
try {
|
|
102
|
+
jsonContent = decompressTraceBuffer(rawBuffer, filePath);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Fast substring pre-filtering to eliminate non-matching files without JSON.parse overhead.
|
|
108
|
+
if (grep && !jsonContent.includes(grep)) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (names && names.length > 0) {
|
|
112
|
+
const hasAnyName = names.some((n) => jsonContent.includes(n));
|
|
113
|
+
if (!hasAnyName) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (category && !jsonContent.includes(category)) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (leafFields && leafFields.length > 0) {
|
|
121
|
+
const hasAllLeaves = leafFields.every((lf) => jsonContent.includes(lf));
|
|
122
|
+
if (!hasAllLeaves) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let rawEvents;
|
|
127
|
+
try {
|
|
128
|
+
rawEvents = parseTraceJson(jsonContent);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
let fileMatches = 0;
|
|
134
|
+
const fileCategories = new Set();
|
|
135
|
+
let firstEventName;
|
|
136
|
+
let firstEventTsMs;
|
|
137
|
+
for (const raw of rawEvents) {
|
|
138
|
+
if (!raw || typeof raw !== 'object') {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const event = raw;
|
|
142
|
+
const eventName = event.name ?? '';
|
|
143
|
+
if (namesSet && !namesSet.has(eventName)) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const eventCat = event.cat;
|
|
147
|
+
if (category) {
|
|
148
|
+
const matchesCategory = Boolean(eventCat && (eventCat === category || eventCat.split(',').includes(category)));
|
|
149
|
+
if (!matchesCategory) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const eventPh = event.ph;
|
|
154
|
+
if (phase && eventPh !== phase) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (grep) {
|
|
158
|
+
const matchesGrep = eventName.includes(grep) ||
|
|
159
|
+
Boolean(eventCat?.includes(grep)) ||
|
|
160
|
+
Boolean(event.args && JSON.stringify(event.args).includes(grep));
|
|
161
|
+
if (!matchesGrep) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (fields && fields.length > 0) {
|
|
166
|
+
let hasAllFields = true;
|
|
167
|
+
for (const field of fields) {
|
|
168
|
+
if (getPropertyByPath(event, field) === undefined) {
|
|
169
|
+
hasAllFields = false;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!hasAllFields) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
fileMatches++;
|
|
178
|
+
if (eventCat) {
|
|
179
|
+
fileCategories.add(eventCat);
|
|
180
|
+
}
|
|
181
|
+
if (firstEventName === undefined) {
|
|
182
|
+
firstEventName = eventName || '(anonymous)';
|
|
183
|
+
firstEventTsMs = microToMilli(event.ts ?? 0);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (fileMatches > 0) {
|
|
187
|
+
totalEventsMatched += fileMatches;
|
|
188
|
+
const relativePath = path.relative(process.cwd(), filePath);
|
|
189
|
+
matchedFiles.push({
|
|
190
|
+
file: relativePath,
|
|
191
|
+
matchCount: fileMatches,
|
|
192
|
+
categories: Array.from(fileCategories).sort(),
|
|
193
|
+
firstEventName,
|
|
194
|
+
firstEventTsMs,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Sort files by match count descending, with secondary alphabetical sort.
|
|
199
|
+
matchedFiles.sort((a, b) => {
|
|
200
|
+
if (b.matchCount !== a.matchCount) {
|
|
201
|
+
return b.matchCount - a.matchCount;
|
|
202
|
+
}
|
|
203
|
+
return a.file.localeCompare(b.file);
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
query: {
|
|
207
|
+
names,
|
|
208
|
+
category,
|
|
209
|
+
phase,
|
|
210
|
+
grep,
|
|
211
|
+
fields,
|
|
212
|
+
},
|
|
213
|
+
totalFilesScanned: files.length,
|
|
214
|
+
totalFilesMatched: matchedFiles.length,
|
|
215
|
+
totalEventsMatched,
|
|
216
|
+
matchedFiles: matchedFiles.slice(0, limit),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type TimeRange } from '../../utils/filter.js';
|
|
2
|
+
import type { LoadedTrace } from '../loader.js';
|
|
3
|
+
import type { SchedulingResult } from '../types.js';
|
|
4
|
+
export interface ExtractSchedulingOptions {
|
|
5
|
+
timeRange?: TimeRange;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Extracts async task and animation frame scheduling, cancellation, and execution metrics.
|
|
9
|
+
*/
|
|
10
|
+
export declare function extractScheduling(loadedTrace: LoadedTrace, options?: ExtractSchedulingOptions): SchedulingResult;
|
|
@@ -0,0 +1,89 @@
|
|
|
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 { isEventInBounds } from '../../utils/filter.js';
|
|
15
|
+
import { microToMilli, round } from '../../utils/units.js';
|
|
16
|
+
import { getMainRendererThread } from '../helpers.js';
|
|
17
|
+
/**
|
|
18
|
+
* Extracts async task and animation frame scheduling, cancellation, and execution metrics.
|
|
19
|
+
*/
|
|
20
|
+
export function extractScheduling(loadedTrace, options) {
|
|
21
|
+
const { parsedTrace, rawEvents } = loadedTrace;
|
|
22
|
+
const timeRange = options?.timeRange;
|
|
23
|
+
const animStats = {
|
|
24
|
+
scheduledCount: 0,
|
|
25
|
+
canceledCount: 0,
|
|
26
|
+
executedCount: 0,
|
|
27
|
+
cancellationRatePct: 0,
|
|
28
|
+
};
|
|
29
|
+
const timerStats = {
|
|
30
|
+
scheduledCount: 0,
|
|
31
|
+
canceledCount: 0,
|
|
32
|
+
executedCount: 0,
|
|
33
|
+
cancellationRatePct: 0,
|
|
34
|
+
};
|
|
35
|
+
const mainThread = getMainRendererThread(parsedTrace.data);
|
|
36
|
+
// biome-ignore lint/suspicious/noExplicitAny: generic event scanning
|
|
37
|
+
const eventsToScan = mainThread?.entries || rawEvents || [];
|
|
38
|
+
for (const entry of eventsToScan) {
|
|
39
|
+
const startMs = microToMilli(entry.ts);
|
|
40
|
+
const durMs = microToMilli(entry.dur ?? 0);
|
|
41
|
+
if (!isEventInBounds(startMs, durMs, timeRange)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const name = entry.name;
|
|
45
|
+
if (name === 'RequestAnimationFrame') {
|
|
46
|
+
animStats.scheduledCount++;
|
|
47
|
+
}
|
|
48
|
+
else if (name === 'CancelAnimationFrame') {
|
|
49
|
+
animStats.canceledCount++;
|
|
50
|
+
}
|
|
51
|
+
else if (name === 'FireAnimationFrame') {
|
|
52
|
+
animStats.executedCount++;
|
|
53
|
+
}
|
|
54
|
+
else if (name === 'TimerInstall') {
|
|
55
|
+
timerStats.scheduledCount++;
|
|
56
|
+
}
|
|
57
|
+
else if (name === 'TimerRemove') {
|
|
58
|
+
timerStats.canceledCount++;
|
|
59
|
+
}
|
|
60
|
+
else if (name === 'TimerFire') {
|
|
61
|
+
timerStats.executedCount++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
animStats.cancellationRatePct =
|
|
65
|
+
animStats.scheduledCount > 0
|
|
66
|
+
? round((animStats.canceledCount / animStats.scheduledCount) * 100, 1)
|
|
67
|
+
: 0;
|
|
68
|
+
timerStats.cancellationRatePct =
|
|
69
|
+
timerStats.scheduledCount > 0
|
|
70
|
+
? round((timerStats.canceledCount / timerStats.scheduledCount) * 100, 1)
|
|
71
|
+
: 0;
|
|
72
|
+
const warnings = [];
|
|
73
|
+
let churnDetected = false;
|
|
74
|
+
if (animStats.cancellationRatePct >= 70 && animStats.scheduledCount >= 5) {
|
|
75
|
+
churnDetected = true;
|
|
76
|
+
warnings.push(`High animation frame cancellation rate (${animStats.cancellationRatePct}%). ${animStats.canceledCount} of ${animStats.scheduledCount} scheduled rAFs were canceled before execution.`);
|
|
77
|
+
}
|
|
78
|
+
if (timerStats.cancellationRatePct >= 70 && timerStats.scheduledCount >= 5) {
|
|
79
|
+
churnDetected = true;
|
|
80
|
+
warnings.push(`High timer cancellation rate (${timerStats.cancellationRatePct}%). ${timerStats.canceledCount} of ${timerStats.scheduledCount} installed timers were removed before firing.`);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
timeRange,
|
|
84
|
+
animationFrames: animStats,
|
|
85
|
+
timers: timerStats,
|
|
86
|
+
churnDetected,
|
|
87
|
+
warnings,
|
|
88
|
+
};
|
|
89
|
+
}
|