@xctrace-analyzer/core 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.
- package/LICENSE +21 -0
- package/README.md +482 -0
- package/dist/analyzer/comparative-analyzer.d.ts +38 -0
- package/dist/analyzer/comparative-analyzer.d.ts.map +1 -0
- package/dist/analyzer/comparative-analyzer.js +255 -0
- package/dist/analyzer/comparative-analyzer.js.map +1 -0
- package/dist/analyzer/performance-analyzer.d.ts +49 -0
- package/dist/analyzer/performance-analyzer.d.ts.map +1 -0
- package/dist/analyzer/performance-analyzer.js +413 -0
- package/dist/analyzer/performance-analyzer.js.map +1 -0
- package/dist/analyzer/recommendation-engine.d.ts +39 -0
- package/dist/analyzer/recommendation-engine.d.ts.map +1 -0
- package/dist/analyzer/recommendation-engine.js +306 -0
- package/dist/analyzer/recommendation-engine.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/parser/trace-parser.d.ts +154 -0
- package/dist/parser/trace-parser.d.ts.map +1 -0
- package/dist/parser/trace-parser.js +1738 -0
- package/dist/parser/trace-parser.js.map +1 -0
- package/dist/types.d.ts +371 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +33 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/xctrace-runner.d.ts +81 -0
- package/dist/utils/xctrace-runner.d.ts.map +1 -0
- package/dist/utils/xctrace-runner.js +420 -0
- package/dist/utils/xctrace-runner.js.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1738 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TraceParser - Parses Xcode Instruments trace files
|
|
3
|
+
*/
|
|
4
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
5
|
+
import { stat } from 'fs/promises';
|
|
6
|
+
import { basename } from 'path';
|
|
7
|
+
import { TraceParserError, } from '../types.js';
|
|
8
|
+
import { exportHAR, exportTable, exportTOC, exportXPath } from '../utils/xctrace-runner.js';
|
|
9
|
+
const defaultExporter = {
|
|
10
|
+
exportTOC,
|
|
11
|
+
exportTable,
|
|
12
|
+
exportXPath,
|
|
13
|
+
exportHAR,
|
|
14
|
+
};
|
|
15
|
+
const INSTRUMENT_ORDER = [
|
|
16
|
+
'memory',
|
|
17
|
+
'network',
|
|
18
|
+
'energy',
|
|
19
|
+
'allocations',
|
|
20
|
+
'leaks',
|
|
21
|
+
];
|
|
22
|
+
const SCHEMA_PATTERNS = {
|
|
23
|
+
memory: [/memory/i, /resident/i, /dirty/i, /vm/i],
|
|
24
|
+
network: [/network/i, /connection/i, /http/i, /url/i],
|
|
25
|
+
energy: [/energy/i, /power/i, /wakeups?/i, /battery/i],
|
|
26
|
+
allocations: [/alloc/i, /malloc/i],
|
|
27
|
+
leaks: [/leaks?/i],
|
|
28
|
+
};
|
|
29
|
+
/** Schemas xctrace uses to expose main-thread stalls. */
|
|
30
|
+
const HANG_SCHEMAS = ['potential-hangs', 'hang-risks'];
|
|
31
|
+
/**
|
|
32
|
+
* Parse xctrace timestamp/duration fmt strings to milliseconds.
|
|
33
|
+
*
|
|
34
|
+
* Supported shapes (all real outputs from xctrace 16):
|
|
35
|
+
* - `"817.10 ms"` / `"11.37 s"` / `"4.76 s"` (duration with unit)
|
|
36
|
+
* - `"00:01.326.520"` (mm:ss.ms.us — used for absolute timestamps)
|
|
37
|
+
* - `"272.78 ms"` (microhang durations)
|
|
38
|
+
*
|
|
39
|
+
* Returns `undefined` when the string can't be interpreted; callers should
|
|
40
|
+
* fall back to other fields.
|
|
41
|
+
*/
|
|
42
|
+
function parseTimestampFmtToMs(fmt) {
|
|
43
|
+
const trimmed = fmt.trim();
|
|
44
|
+
if (!trimmed)
|
|
45
|
+
return undefined;
|
|
46
|
+
// mm:ss.ms.us — convert each segment.
|
|
47
|
+
const colonMatch = trimmed.match(/^(\d+):(\d+)\.(\d+)(?:\.(\d+))?$/);
|
|
48
|
+
if (colonMatch) {
|
|
49
|
+
const minutes = Number(colonMatch[1]);
|
|
50
|
+
const seconds = Number(colonMatch[2]);
|
|
51
|
+
const ms = Number(colonMatch[3]);
|
|
52
|
+
const us = colonMatch[4] !== undefined ? Number(colonMatch[4]) : 0;
|
|
53
|
+
return minutes * 60_000 + seconds * 1000 + ms + us / 1000;
|
|
54
|
+
}
|
|
55
|
+
// value + unit
|
|
56
|
+
const unitMatch = trimmed.match(/^(\d+(?:\.\d+)?)\s*(ns|us|µs|ms|s|m|h)$/i);
|
|
57
|
+
if (unitMatch) {
|
|
58
|
+
const value = Number(unitMatch[1]);
|
|
59
|
+
const unit = unitMatch[2].toLowerCase();
|
|
60
|
+
switch (unit) {
|
|
61
|
+
case 'ns':
|
|
62
|
+
return value / 1e6;
|
|
63
|
+
case 'us':
|
|
64
|
+
case 'µs':
|
|
65
|
+
return value / 1000;
|
|
66
|
+
case 'ms':
|
|
67
|
+
return value;
|
|
68
|
+
case 's':
|
|
69
|
+
return value * 1000;
|
|
70
|
+
case 'm':
|
|
71
|
+
return value * 60_000;
|
|
72
|
+
case 'h':
|
|
73
|
+
return value * 3_600_000;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const NETWORK_RAW_EXPORT_DENY_PATTERNS = [
|
|
79
|
+
/cfnetwork/i,
|
|
80
|
+
/drawables?/i,
|
|
81
|
+
/intervals?/i,
|
|
82
|
+
/aggregation/i,
|
|
83
|
+
/harlogging/i,
|
|
84
|
+
];
|
|
85
|
+
const BYTE_UNIT_MULTIPLIERS = {
|
|
86
|
+
b: 1,
|
|
87
|
+
byte: 1,
|
|
88
|
+
bytes: 1,
|
|
89
|
+
kb: 1024,
|
|
90
|
+
kib: 1024,
|
|
91
|
+
mb: 1024 * 1024,
|
|
92
|
+
mib: 1024 * 1024,
|
|
93
|
+
gb: 1024 * 1024 * 1024,
|
|
94
|
+
gib: 1024 * 1024 * 1024,
|
|
95
|
+
};
|
|
96
|
+
const NUMERIC_UNITS = new Set([
|
|
97
|
+
'%',
|
|
98
|
+
'percent',
|
|
99
|
+
'ms',
|
|
100
|
+
's',
|
|
101
|
+
'sec',
|
|
102
|
+
'secs',
|
|
103
|
+
'second',
|
|
104
|
+
'seconds',
|
|
105
|
+
'm',
|
|
106
|
+
'min',
|
|
107
|
+
'mins',
|
|
108
|
+
'minute',
|
|
109
|
+
'minutes',
|
|
110
|
+
'h',
|
|
111
|
+
'hr',
|
|
112
|
+
'hrs',
|
|
113
|
+
'hour',
|
|
114
|
+
'hours',
|
|
115
|
+
'count',
|
|
116
|
+
'counts',
|
|
117
|
+
'sample',
|
|
118
|
+
'samples',
|
|
119
|
+
'request',
|
|
120
|
+
'requests',
|
|
121
|
+
'wakeups',
|
|
122
|
+
]);
|
|
123
|
+
const MAX_XML_EXPORT_BYTES = 50 * 1024 * 1024;
|
|
124
|
+
const MAX_HAR_EXPORT_BYTES = 25 * 1024 * 1024;
|
|
125
|
+
const MAX_TABLE_ROWS = 100_000;
|
|
126
|
+
const MAX_HAR_ENTRIES = 25_000;
|
|
127
|
+
const MAX_XML_DEPTH = 200;
|
|
128
|
+
/**
|
|
129
|
+
* Main parser class for Xcode Instruments traces
|
|
130
|
+
*/
|
|
131
|
+
export class TraceParser {
|
|
132
|
+
xmlParser;
|
|
133
|
+
exporter;
|
|
134
|
+
exportAttempts = [];
|
|
135
|
+
constructor(exporter = defaultExporter) {
|
|
136
|
+
this.exporter = exporter;
|
|
137
|
+
this.xmlParser = new XMLParser({
|
|
138
|
+
ignoreAttributes: false,
|
|
139
|
+
attributeNamePrefix: '@_',
|
|
140
|
+
textNodeName: '#text',
|
|
141
|
+
parseAttributeValue: true,
|
|
142
|
+
trimValues: true,
|
|
143
|
+
processEntities: false,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Parse a complete trace file
|
|
148
|
+
*/
|
|
149
|
+
async parseTrace(tracePath, options = {}) {
|
|
150
|
+
try {
|
|
151
|
+
this.exportAttempts = [];
|
|
152
|
+
// Validate trace file exists
|
|
153
|
+
await this.validateTraceFile(tracePath);
|
|
154
|
+
// Get metadata and table schemas
|
|
155
|
+
const { metadata, schemas, tableDescriptors, guiTrackNamesByKind } = await this.extractMetadataAndSchemas(tracePath);
|
|
156
|
+
// Parse time profile data
|
|
157
|
+
const timeProfile = await this.parseTimeProfile(tracePath, schemas, tableDescriptors, options);
|
|
158
|
+
const hangs = await this.parseHangs(tracePath, schemas, tableDescriptors, options);
|
|
159
|
+
const instrumentAnalyses = await this.parseInstrumentAnalyses(tracePath, schemas, tableDescriptors);
|
|
160
|
+
const supportStatus = this.buildSupportStatus(schemas, timeProfile, instrumentAnalyses, guiTrackNamesByKind);
|
|
161
|
+
return {
|
|
162
|
+
metadata,
|
|
163
|
+
timeProfile,
|
|
164
|
+
hangs,
|
|
165
|
+
instrumentAnalyses,
|
|
166
|
+
tableDescriptors,
|
|
167
|
+
exportAttempts: [...this.exportAttempts],
|
|
168
|
+
supportStatus,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error instanceof TraceParserError) {
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
throw new TraceParserError(`Failed to parse trace: ${tracePath}`, error);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Validate that the trace file exists and is readable
|
|
180
|
+
*/
|
|
181
|
+
async validateTraceFile(tracePath) {
|
|
182
|
+
try {
|
|
183
|
+
const stats = await stat(tracePath);
|
|
184
|
+
if (!stats.isFile() && !stats.isDirectory()) {
|
|
185
|
+
throw new TraceParserError(`Path is not a valid trace file: ${tracePath}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error.code === 'ENOENT') {
|
|
190
|
+
throw new TraceParserError(`Trace file not found: ${tracePath}`);
|
|
191
|
+
}
|
|
192
|
+
throw new TraceParserError(`Cannot access trace file: ${tracePath}`, error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Extract metadata from trace file
|
|
197
|
+
*/
|
|
198
|
+
async extractMetadataAndSchemas(tracePath) {
|
|
199
|
+
try {
|
|
200
|
+
const tocXML = await this.exporter.exportTOC(tracePath);
|
|
201
|
+
const tocData = this.parseXml(tocXML, 'trace TOC');
|
|
202
|
+
const schemas = this.extractSchemas(tocData);
|
|
203
|
+
const tableDescriptors = this.extractTableDescriptors(tocData);
|
|
204
|
+
const userProcessNames = this.extractUserProcessNames(tocData);
|
|
205
|
+
const guiTrackNamesByKind = this.extractGuiTrackNamesByKind(tocData);
|
|
206
|
+
this.exportAttempts.push({
|
|
207
|
+
kind: 'toc',
|
|
208
|
+
status: schemas.length > 0 ? 'success' : 'empty',
|
|
209
|
+
});
|
|
210
|
+
// Extract metadata from TOC
|
|
211
|
+
const run = tocData['trace-toc']?.run;
|
|
212
|
+
if (!run) {
|
|
213
|
+
throw new TraceParserError('Invalid trace TOC structure');
|
|
214
|
+
}
|
|
215
|
+
const metadata = {
|
|
216
|
+
fileName: basename(tracePath),
|
|
217
|
+
filePath: tracePath,
|
|
218
|
+
duration: 0,
|
|
219
|
+
template: 'Unknown',
|
|
220
|
+
};
|
|
221
|
+
if (userProcessNames.length > 0) {
|
|
222
|
+
metadata.userProcessNames = userProcessNames;
|
|
223
|
+
}
|
|
224
|
+
// Try to extract various metadata fields
|
|
225
|
+
if (run['@_number']) {
|
|
226
|
+
// Run exists, try to get more info
|
|
227
|
+
}
|
|
228
|
+
// Get file stats for timestamp
|
|
229
|
+
const stats = await stat(tracePath);
|
|
230
|
+
metadata.recordedAt = stats.mtime;
|
|
231
|
+
// Parse duration if available
|
|
232
|
+
if (run.duration) {
|
|
233
|
+
metadata.duration = this.parseDuration(run.duration);
|
|
234
|
+
}
|
|
235
|
+
return { metadata, schemas, tableDescriptors, guiTrackNamesByKind };
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
this.exportAttempts.push({
|
|
239
|
+
kind: 'toc',
|
|
240
|
+
status: 'failed',
|
|
241
|
+
message: error.message,
|
|
242
|
+
});
|
|
243
|
+
// Return minimal metadata if we can't parse TOC
|
|
244
|
+
return {
|
|
245
|
+
metadata: {
|
|
246
|
+
fileName: basename(tracePath),
|
|
247
|
+
filePath: tracePath,
|
|
248
|
+
duration: 0,
|
|
249
|
+
template: 'Unknown',
|
|
250
|
+
},
|
|
251
|
+
schemas: [],
|
|
252
|
+
tableDescriptors: [],
|
|
253
|
+
guiTrackNamesByKind: {},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Parse Time Profiler data from trace
|
|
259
|
+
*/
|
|
260
|
+
async parseTimeProfile(tracePath, schemas, tableDescriptors, options) {
|
|
261
|
+
if (schemas.length > 0 && !schemas.includes('time-profile')) {
|
|
262
|
+
this.exportAttempts.push({
|
|
263
|
+
kind: 'time-profile',
|
|
264
|
+
status: 'skipped',
|
|
265
|
+
schema: 'time-profile',
|
|
266
|
+
message: 'The trace TOC does not expose a Time Profiler table.',
|
|
267
|
+
});
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const descriptor = tableDescriptors.find((table) => table.schema === 'time-profile');
|
|
272
|
+
const xmlData = descriptor?.xpath && this.exporter.exportXPath
|
|
273
|
+
? await this.exporter.exportXPath(tracePath, descriptor.xpath)
|
|
274
|
+
: await this.exporter.exportTable(tracePath, 'time-profile', descriptor?.runNumber);
|
|
275
|
+
if (!xmlData || xmlData.trim() === '') {
|
|
276
|
+
// No time profile data available
|
|
277
|
+
this.exportAttempts.push({
|
|
278
|
+
kind: 'time-profile',
|
|
279
|
+
status: 'empty',
|
|
280
|
+
schema: 'time-profile',
|
|
281
|
+
xpath: descriptor?.xpath,
|
|
282
|
+
});
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
const parsed = this.parseXml(xmlData, 'Time Profiler table');
|
|
286
|
+
// Extract table data. Recent xctrace versions wrap XPath exports in
|
|
287
|
+
// trace-query-result/node instead of returning a bare table element.
|
|
288
|
+
const table = parsed.table ?? parsed['trace-query-result']?.node;
|
|
289
|
+
if (!table) {
|
|
290
|
+
this.exportAttempts.push({
|
|
291
|
+
kind: 'time-profile',
|
|
292
|
+
status: 'empty',
|
|
293
|
+
schema: 'time-profile',
|
|
294
|
+
xpath: descriptor?.xpath,
|
|
295
|
+
message: 'No table rows were exported for Time Profiler.',
|
|
296
|
+
});
|
|
297
|
+
return undefined;
|
|
298
|
+
}
|
|
299
|
+
// Parse rows into samples before applying any analysis window.
|
|
300
|
+
const rawSamples = [];
|
|
301
|
+
// Parse table rows
|
|
302
|
+
const rows = this.limitRows(this.rowsFromTableNode(table), 'Time Profiler');
|
|
303
|
+
for (const row of rows) {
|
|
304
|
+
if (!row)
|
|
305
|
+
continue;
|
|
306
|
+
// Extract sample data
|
|
307
|
+
const sample = this.parseSampleRow(row);
|
|
308
|
+
if (sample) {
|
|
309
|
+
rawSamples.push(sample);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const samples = this.filterSamplesByTimeRange(rawSamples, options.timeRangeMs);
|
|
313
|
+
const functionMap = new Map();
|
|
314
|
+
for (const sample of samples) {
|
|
315
|
+
this.aggregateFunctionProfiles(sample, functionMap);
|
|
316
|
+
}
|
|
317
|
+
this.exportAttempts.push({
|
|
318
|
+
kind: 'time-profile',
|
|
319
|
+
status: rawSamples.length > 0 ? 'success' : 'empty',
|
|
320
|
+
schema: 'time-profile',
|
|
321
|
+
xpath: descriptor?.xpath,
|
|
322
|
+
});
|
|
323
|
+
// Convert function map to sorted array
|
|
324
|
+
const functionProfiles = Array.from(functionMap.values())
|
|
325
|
+
.sort((a, b) => b.totalTime - a.totalTime);
|
|
326
|
+
// Calculate total duration from samples
|
|
327
|
+
const totalDuration = this.timeProfileTotalDuration(samples, options.timeRangeMs);
|
|
328
|
+
return {
|
|
329
|
+
totalDuration,
|
|
330
|
+
samples,
|
|
331
|
+
functionProfiles,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
this.exportAttempts.push({
|
|
336
|
+
kind: 'time-profile',
|
|
337
|
+
status: 'failed',
|
|
338
|
+
schema: 'time-profile',
|
|
339
|
+
message: error.message,
|
|
340
|
+
});
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Parse supported non-Time-Profiler instrument data.
|
|
346
|
+
*/
|
|
347
|
+
async parseInstrumentAnalyses(tracePath, schemas, tableDescriptors) {
|
|
348
|
+
const analyses = [];
|
|
349
|
+
for (const kind of INSTRUMENT_ORDER) {
|
|
350
|
+
const matchingSchemas = this.matchSchemasForKind(schemas, kind);
|
|
351
|
+
if (kind === 'network') {
|
|
352
|
+
const network = await this.parseNetworkAnalysis(tracePath, matchingSchemas, tableDescriptors);
|
|
353
|
+
if (network) {
|
|
354
|
+
analyses.push(network);
|
|
355
|
+
}
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (matchingSchemas.length === 0) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const rows = await this.exportRowsForSchemas(tracePath, matchingSchemas, tableDescriptors, kind);
|
|
362
|
+
const analysis = this.analyzeRowsForKind(kind, rows, matchingSchemas);
|
|
363
|
+
if (analysis) {
|
|
364
|
+
analyses.push(analysis);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return analyses;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Parse main-thread hang events from `potential-hangs` and `hang-risks`.
|
|
371
|
+
*
|
|
372
|
+
* xctrace exposes these schemas in every macOS/iOS trace; each row carries a
|
|
373
|
+
* `start-time`, `duration`, and `hang-type` (`Hang` / `Severe Hang` /
|
|
374
|
+
* `Microhang`), plus thread/process metadata. Returns `undefined` when no
|
|
375
|
+
* hang schemas are present (most empty traces).
|
|
376
|
+
*/
|
|
377
|
+
async parseHangs(tracePath, schemas, tableDescriptors, options) {
|
|
378
|
+
const presentSchemas = HANG_SCHEMAS.filter((schema) => schemas.includes(schema));
|
|
379
|
+
if (presentSchemas.length === 0) {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
const events = [];
|
|
383
|
+
const sourceSchemas = [];
|
|
384
|
+
for (const schema of presentSchemas) {
|
|
385
|
+
const descriptor = tableDescriptors.find((table) => table.schema === schema);
|
|
386
|
+
try {
|
|
387
|
+
const xmlData = descriptor?.xpath && this.exporter.exportXPath
|
|
388
|
+
? await this.exporter.exportXPath(tracePath, descriptor.xpath)
|
|
389
|
+
: await this.exporter.exportTable(tracePath, schema, descriptor?.runNumber);
|
|
390
|
+
if (!xmlData || xmlData.trim() === '') {
|
|
391
|
+
this.exportAttempts.push({
|
|
392
|
+
kind: 'hangs',
|
|
393
|
+
status: 'empty',
|
|
394
|
+
schema,
|
|
395
|
+
xpath: descriptor?.xpath,
|
|
396
|
+
});
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
const parsed = this.parseXml(xmlData, `${schema} table`);
|
|
400
|
+
const table = parsed.table ?? parsed['trace-query-result']?.node;
|
|
401
|
+
if (!table) {
|
|
402
|
+
this.exportAttempts.push({
|
|
403
|
+
kind: 'hangs',
|
|
404
|
+
status: 'empty',
|
|
405
|
+
schema,
|
|
406
|
+
xpath: descriptor?.xpath,
|
|
407
|
+
message: `No table rows were exported for ${schema}.`,
|
|
408
|
+
});
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const rawRows = this.limitRows(this.rowsFromTableNode(table), schema);
|
|
412
|
+
const resolved = this.resolveIdRefs(rawRows);
|
|
413
|
+
const schemaEvents = resolved
|
|
414
|
+
.map((row) => this.parseHangRow(row, schema))
|
|
415
|
+
.filter((event) => event !== undefined);
|
|
416
|
+
const scopedEvents = this.filterHangEventsByTimeRange(schemaEvents, options.timeRangeMs);
|
|
417
|
+
if (scopedEvents.length > 0) {
|
|
418
|
+
events.push(...scopedEvents);
|
|
419
|
+
sourceSchemas.push(schema);
|
|
420
|
+
}
|
|
421
|
+
this.exportAttempts.push({
|
|
422
|
+
kind: 'hangs',
|
|
423
|
+
status: schemaEvents.length > 0 ? 'success' : 'empty',
|
|
424
|
+
schema,
|
|
425
|
+
xpath: descriptor?.xpath,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
this.exportAttempts.push({
|
|
430
|
+
kind: 'hangs',
|
|
431
|
+
status: 'failed',
|
|
432
|
+
schema,
|
|
433
|
+
xpath: descriptor?.xpath,
|
|
434
|
+
message: error.message,
|
|
435
|
+
});
|
|
436
|
+
// Don't AND-gate across descriptors — keep going so events from a
|
|
437
|
+
// sibling schema (e.g. `potential-hangs`) still surface even if
|
|
438
|
+
// `hang-risks` failed.
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (events.length === 0) {
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
events.sort((a, b) => a.startMs - b.startMs);
|
|
445
|
+
return this.summarizeHangs(events, sourceSchemas);
|
|
446
|
+
}
|
|
447
|
+
parseHangRow(row, schema) {
|
|
448
|
+
const startMs = this.readDurationFieldMs(row['start-time']);
|
|
449
|
+
const durationMs = this.readDurationFieldMs(row.duration);
|
|
450
|
+
const hangType = this.readTextField(row['hang-type']);
|
|
451
|
+
if (startMs === undefined || durationMs === undefined || !hangType) {
|
|
452
|
+
return undefined;
|
|
453
|
+
}
|
|
454
|
+
const thread = row.thread;
|
|
455
|
+
const threadName = this.readFmtField(thread);
|
|
456
|
+
const tidValue = this.readNumericField(thread?.tid);
|
|
457
|
+
const processNode = row.process ?? thread?.process;
|
|
458
|
+
const processName = this.readFmtField(processNode);
|
|
459
|
+
const pidValue = this.readNumericField(processNode?.pid);
|
|
460
|
+
const event = {
|
|
461
|
+
startMs,
|
|
462
|
+
durationMs,
|
|
463
|
+
hangType,
|
|
464
|
+
schemaSource: schema,
|
|
465
|
+
};
|
|
466
|
+
if (threadName !== undefined)
|
|
467
|
+
event.threadName = threadName;
|
|
468
|
+
if (tidValue !== undefined)
|
|
469
|
+
event.threadId = tidValue;
|
|
470
|
+
if (processName !== undefined)
|
|
471
|
+
event.processName = processName;
|
|
472
|
+
if (pidValue !== undefined)
|
|
473
|
+
event.pid = pidValue;
|
|
474
|
+
return event;
|
|
475
|
+
}
|
|
476
|
+
summarizeHangs(events, sourceSchemas) {
|
|
477
|
+
let totalHangMs = 0;
|
|
478
|
+
let severeCount = 0;
|
|
479
|
+
let hangCount = 0;
|
|
480
|
+
let microhangCount = 0;
|
|
481
|
+
let longestMs = 0;
|
|
482
|
+
for (const event of events) {
|
|
483
|
+
totalHangMs += event.durationMs;
|
|
484
|
+
if (event.durationMs > longestMs)
|
|
485
|
+
longestMs = event.durationMs;
|
|
486
|
+
switch (event.hangType) {
|
|
487
|
+
case 'Severe Hang':
|
|
488
|
+
severeCount += 1;
|
|
489
|
+
break;
|
|
490
|
+
case 'Microhang':
|
|
491
|
+
microhangCount += 1;
|
|
492
|
+
break;
|
|
493
|
+
case 'Hang':
|
|
494
|
+
hangCount += 1;
|
|
495
|
+
break;
|
|
496
|
+
default:
|
|
497
|
+
// Forward-compat: unknown classifications fall through silently.
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
events,
|
|
503
|
+
totalHangMs,
|
|
504
|
+
severeCount,
|
|
505
|
+
hangCount,
|
|
506
|
+
microhangCount,
|
|
507
|
+
longestMs,
|
|
508
|
+
sourceSchemas,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
filterSamplesByTimeRange(samples, timeRangeMs) {
|
|
512
|
+
if (!timeRangeMs) {
|
|
513
|
+
return samples;
|
|
514
|
+
}
|
|
515
|
+
return samples.filter((sample) => sample.timestamp >= timeRangeMs.startMs && sample.timestamp <= timeRangeMs.endMs);
|
|
516
|
+
}
|
|
517
|
+
filterHangEventsByTimeRange(events, timeRangeMs) {
|
|
518
|
+
if (!timeRangeMs) {
|
|
519
|
+
return events;
|
|
520
|
+
}
|
|
521
|
+
return events.filter((event) => {
|
|
522
|
+
const eventEndMs = event.startMs + event.durationMs;
|
|
523
|
+
return event.startMs <= timeRangeMs.endMs && eventEndMs >= timeRangeMs.startMs;
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
timeProfileTotalDuration(samples, timeRangeMs) {
|
|
527
|
+
if (timeRangeMs) {
|
|
528
|
+
return Math.max(0, timeRangeMs.endMs - timeRangeMs.startMs);
|
|
529
|
+
}
|
|
530
|
+
return samples.length > 0
|
|
531
|
+
? Math.max(...samples.map(s => s.timestamp))
|
|
532
|
+
: 0;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* xctrace XML interns repeated values: the first occurrence has `id="N"`
|
|
536
|
+
* with full data, every subsequent occurrence is `<element ref="N"/>`.
|
|
537
|
+
* Without resolution every row after the first loses its thread/process
|
|
538
|
+
* info. Walk the rows once collecting `id -> node`, then deep-clone each
|
|
539
|
+
* row substituting any `ref` for its captured node.
|
|
540
|
+
*/
|
|
541
|
+
resolveIdRefs(rows) {
|
|
542
|
+
const idMap = new Map();
|
|
543
|
+
const collect = (value, depth = 0) => {
|
|
544
|
+
if (value === null || typeof value !== 'object')
|
|
545
|
+
return;
|
|
546
|
+
if (depth > MAX_XML_DEPTH) {
|
|
547
|
+
throw new TraceParserError(`XML id/ref nesting exceeded the ${MAX_XML_DEPTH} level safety limit`);
|
|
548
|
+
}
|
|
549
|
+
if (Array.isArray(value)) {
|
|
550
|
+
for (const item of value)
|
|
551
|
+
collect(item, depth + 1);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const id = value['@_id'];
|
|
555
|
+
if (id !== undefined && id !== null) {
|
|
556
|
+
idMap.set(String(id), value);
|
|
557
|
+
}
|
|
558
|
+
for (const [key, child] of Object.entries(value)) {
|
|
559
|
+
if (key.startsWith('@_'))
|
|
560
|
+
continue;
|
|
561
|
+
collect(child, depth + 1);
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
for (const row of rows)
|
|
565
|
+
collect(row);
|
|
566
|
+
const resolve = (value, seenRefs = new Set(), depth = 0) => {
|
|
567
|
+
if (value === null || typeof value !== 'object')
|
|
568
|
+
return value;
|
|
569
|
+
if (depth > MAX_XML_DEPTH) {
|
|
570
|
+
throw new TraceParserError(`XML id/ref nesting exceeded the ${MAX_XML_DEPTH} level safety limit`);
|
|
571
|
+
}
|
|
572
|
+
if (Array.isArray(value))
|
|
573
|
+
return value.map((item) => resolve(item, seenRefs, depth + 1));
|
|
574
|
+
const refId = value['@_ref'];
|
|
575
|
+
if (refId !== undefined && refId !== null) {
|
|
576
|
+
const key = String(refId);
|
|
577
|
+
if (seenRefs.has(key)) {
|
|
578
|
+
throw new TraceParserError(`Cyclic XML id/ref relationship detected for id ${key}`);
|
|
579
|
+
}
|
|
580
|
+
const captured = idMap.get(key);
|
|
581
|
+
if (captured !== undefined) {
|
|
582
|
+
// Resolve the captured node recursively in case it itself contains refs.
|
|
583
|
+
return resolve(captured, new Set([...seenRefs, key]), depth + 1);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const out = {};
|
|
587
|
+
for (const [key, child] of Object.entries(value)) {
|
|
588
|
+
if (key.startsWith('@_')) {
|
|
589
|
+
out[key] = child;
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
out[key] = resolve(child, seenRefs, depth + 1);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return out;
|
|
596
|
+
};
|
|
597
|
+
return rows.map((row) => resolve(row));
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Read the duration of a `<start-time>` / `<duration>` element, in
|
|
601
|
+
* milliseconds. xctrace emits the raw nanosecond count as the element's
|
|
602
|
+
* text content and a pretty form (e.g. `"817.10 ms"` or `"00:01.326.520"`)
|
|
603
|
+
* as `@_fmt`. Prefer the text (always ns); fall back to fmt parsing.
|
|
604
|
+
*/
|
|
605
|
+
readDurationFieldMs(field) {
|
|
606
|
+
if (field === undefined || field === null)
|
|
607
|
+
return undefined;
|
|
608
|
+
if (typeof field === 'number')
|
|
609
|
+
return field / 1e6;
|
|
610
|
+
if (typeof field === 'string') {
|
|
611
|
+
const ns = Number(field);
|
|
612
|
+
if (Number.isFinite(ns))
|
|
613
|
+
return ns / 1e6;
|
|
614
|
+
return parseTimestampFmtToMs(field);
|
|
615
|
+
}
|
|
616
|
+
if (typeof field === 'object') {
|
|
617
|
+
const text = field['#text'];
|
|
618
|
+
if (typeof text === 'number')
|
|
619
|
+
return text / 1e6;
|
|
620
|
+
if (typeof text === 'string') {
|
|
621
|
+
const ns = Number(text);
|
|
622
|
+
if (Number.isFinite(ns))
|
|
623
|
+
return ns / 1e6;
|
|
624
|
+
}
|
|
625
|
+
const fmt = field['@_fmt'];
|
|
626
|
+
if (typeof fmt === 'string')
|
|
627
|
+
return parseTimestampFmtToMs(fmt);
|
|
628
|
+
}
|
|
629
|
+
return undefined;
|
|
630
|
+
}
|
|
631
|
+
readTextField(field) {
|
|
632
|
+
if (field === undefined || field === null)
|
|
633
|
+
return undefined;
|
|
634
|
+
if (typeof field === 'string')
|
|
635
|
+
return field;
|
|
636
|
+
if (typeof field === 'number')
|
|
637
|
+
return String(field);
|
|
638
|
+
if (typeof field === 'object') {
|
|
639
|
+
const text = field['#text'];
|
|
640
|
+
if (typeof text === 'string')
|
|
641
|
+
return text;
|
|
642
|
+
if (typeof text === 'number')
|
|
643
|
+
return String(text);
|
|
644
|
+
const fmt = field['@_fmt'];
|
|
645
|
+
if (typeof fmt === 'string')
|
|
646
|
+
return fmt;
|
|
647
|
+
}
|
|
648
|
+
return undefined;
|
|
649
|
+
}
|
|
650
|
+
readFmtField(field) {
|
|
651
|
+
if (field === undefined || field === null)
|
|
652
|
+
return undefined;
|
|
653
|
+
if (typeof field === 'object') {
|
|
654
|
+
const fmt = field['@_fmt'];
|
|
655
|
+
if (typeof fmt === 'string' && fmt.length > 0)
|
|
656
|
+
return fmt;
|
|
657
|
+
}
|
|
658
|
+
return undefined;
|
|
659
|
+
}
|
|
660
|
+
readNumericField(field) {
|
|
661
|
+
if (field === undefined || field === null)
|
|
662
|
+
return undefined;
|
|
663
|
+
if (typeof field === 'number')
|
|
664
|
+
return field;
|
|
665
|
+
if (typeof field === 'string') {
|
|
666
|
+
const n = Number(field);
|
|
667
|
+
return Number.isFinite(n) ? n : undefined;
|
|
668
|
+
}
|
|
669
|
+
if (typeof field === 'object') {
|
|
670
|
+
const text = field['#text'];
|
|
671
|
+
if (typeof text === 'number')
|
|
672
|
+
return text;
|
|
673
|
+
if (typeof text === 'string') {
|
|
674
|
+
const n = Number(text);
|
|
675
|
+
if (Number.isFinite(n))
|
|
676
|
+
return n;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return undefined;
|
|
680
|
+
}
|
|
681
|
+
async parseNetworkAnalysis(tracePath, matchingSchemas, tableDescriptors) {
|
|
682
|
+
const har = await this.tryExportHAR(tracePath);
|
|
683
|
+
if (har) {
|
|
684
|
+
return this.analyzeNetworkHAR(har, ['har', ...matchingSchemas]);
|
|
685
|
+
}
|
|
686
|
+
if (matchingSchemas.length === 0) {
|
|
687
|
+
return undefined;
|
|
688
|
+
}
|
|
689
|
+
const exportableSchemas = this.networkSchemasSafeForRawExport(matchingSchemas);
|
|
690
|
+
if (exportableSchemas.length === 0) {
|
|
691
|
+
return this.analyzeRowsForKind('network', this.unexportedRowsForSchemas(matchingSchemas), matchingSchemas);
|
|
692
|
+
}
|
|
693
|
+
const rows = await this.exportRowsForSchemas(tracePath, exportableSchemas, tableDescriptors, 'network');
|
|
694
|
+
const skippedSchemas = matchingSchemas.filter((schema) => !exportableSchemas.includes(schema));
|
|
695
|
+
for (const schema of skippedSchemas) {
|
|
696
|
+
this.exportAttempts.push({
|
|
697
|
+
kind: 'network',
|
|
698
|
+
status: 'skipped',
|
|
699
|
+
schema,
|
|
700
|
+
message: 'Schema is known to be unsafe or not useful for raw XML export; HAR is preferred.',
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
rows.push(...this.unexportedRowsForSchemas(skippedSchemas));
|
|
704
|
+
return this.analyzeRowsForKind('network', rows, matchingSchemas);
|
|
705
|
+
}
|
|
706
|
+
async tryExportHAR(tracePath) {
|
|
707
|
+
if (!this.exporter.exportHAR) {
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
710
|
+
try {
|
|
711
|
+
const har = await this.exporter.exportHAR(tracePath);
|
|
712
|
+
if (!har || !har.trim()) {
|
|
713
|
+
this.exportAttempts.push({ kind: 'har', status: 'empty' });
|
|
714
|
+
return undefined;
|
|
715
|
+
}
|
|
716
|
+
this.assertInputSize(har, MAX_HAR_EXPORT_BYTES, 'HAR export');
|
|
717
|
+
const parsedHar = JSON.parse(har);
|
|
718
|
+
const entries = Array.isArray(parsedHar?.log?.entries) ? parsedHar.log.entries : [];
|
|
719
|
+
if (entries.length > MAX_HAR_ENTRIES) {
|
|
720
|
+
this.exportAttempts.push({
|
|
721
|
+
kind: 'har',
|
|
722
|
+
status: 'failed',
|
|
723
|
+
message: `HAR export contains ${entries.length} entries, exceeding the ${MAX_HAR_ENTRIES} entry safety limit.`,
|
|
724
|
+
});
|
|
725
|
+
return undefined;
|
|
726
|
+
}
|
|
727
|
+
this.exportAttempts.push({ kind: 'har', status: 'success' });
|
|
728
|
+
return parsedHar;
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
this.exportAttempts.push({ kind: 'har', status: 'failed' });
|
|
732
|
+
return undefined;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
analyzeRowsForKind(kind, rows, sourceSchemas) {
|
|
736
|
+
switch (kind) {
|
|
737
|
+
case 'memory':
|
|
738
|
+
return this.analyzeMemoryRows(rows, sourceSchemas);
|
|
739
|
+
case 'network':
|
|
740
|
+
return this.analyzeNetworkRows(rows, sourceSchemas);
|
|
741
|
+
case 'energy':
|
|
742
|
+
return this.analyzeEnergyRows(rows, sourceSchemas);
|
|
743
|
+
case 'allocations':
|
|
744
|
+
return this.analyzeAllocationRows(rows, sourceSchemas);
|
|
745
|
+
case 'leaks':
|
|
746
|
+
return this.analyzeLeakRows(rows, sourceSchemas);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
buildSupportStatus(schemas, timeProfile, instrumentAnalyses, guiTrackNamesByKind = {}) {
|
|
750
|
+
const tocFailure = this.exportAttempts.find((attempt) => attempt.kind === 'toc' && attempt.status === 'failed');
|
|
751
|
+
if (tocFailure) {
|
|
752
|
+
const reason = tocFailure.message
|
|
753
|
+
? `xctrace could not export the trace TOC: ${tocFailure.message}`
|
|
754
|
+
: 'xctrace could not export the trace TOC; analysis data is unavailable.';
|
|
755
|
+
return ['time-profile', ...INSTRUMENT_ORDER].map((kind) => ({
|
|
756
|
+
kind,
|
|
757
|
+
status: 'not_exportable',
|
|
758
|
+
reason,
|
|
759
|
+
sourceSchemas: [],
|
|
760
|
+
}));
|
|
761
|
+
}
|
|
762
|
+
const statuses = [];
|
|
763
|
+
const analyses = new Map(instrumentAnalyses.map((analysis) => [analysis.kind, analysis]));
|
|
764
|
+
const hasTimeProfileSchema = schemas.includes('time-profile');
|
|
765
|
+
const timeProfileAttempts = this.exportAttemptsForSupportKind('time-profile');
|
|
766
|
+
const timeProfileStatus = this.statusFromExportAttempts(timeProfileAttempts, hasTimeProfileSchema || !!timeProfile);
|
|
767
|
+
statuses.push({
|
|
768
|
+
kind: 'time-profile',
|
|
769
|
+
status: timeProfileStatus,
|
|
770
|
+
reason: this.supportReason('time-profile', timeProfileStatus, timeProfileAttempts),
|
|
771
|
+
sourceSchemas: hasTimeProfileSchema || timeProfileAttempts.some((attempt) => attempt.status === 'success')
|
|
772
|
+
? ['time-profile']
|
|
773
|
+
: [],
|
|
774
|
+
});
|
|
775
|
+
for (const kind of INSTRUMENT_ORDER) {
|
|
776
|
+
const matchingSchemas = this.matchSchemasForKind(schemas, kind);
|
|
777
|
+
const analysis = analyses.get(kind);
|
|
778
|
+
const attempts = this.exportAttemptsForSupportKind(kind);
|
|
779
|
+
const sourceTracks = guiTrackNamesByKind[kind] ?? [];
|
|
780
|
+
const hasSignal = matchingSchemas.length > 0 || !!analysis || sourceTracks.length > 0;
|
|
781
|
+
const status = this.statusFromExportAttempts(attempts, hasSignal);
|
|
782
|
+
const reason = this.supportReason(kind, status, attempts, sourceTracks);
|
|
783
|
+
if (analysis) {
|
|
784
|
+
analysis.supportStatus = status;
|
|
785
|
+
}
|
|
786
|
+
statuses.push({
|
|
787
|
+
kind,
|
|
788
|
+
status,
|
|
789
|
+
reason,
|
|
790
|
+
sourceSchemas: analysis?.sourceSchemas ?? matchingSchemas,
|
|
791
|
+
...(sourceTracks.length > 0 ? { sourceTracks } : {}),
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
return statuses;
|
|
795
|
+
}
|
|
796
|
+
exportAttemptsForSupportKind(kind) {
|
|
797
|
+
return this.exportAttempts.filter((attempt) => attempt.kind === kind || (kind === 'network' && attempt.kind === 'har'));
|
|
798
|
+
}
|
|
799
|
+
statusFromExportAttempts(attempts, hasSchemaOrAnalysis) {
|
|
800
|
+
const success = attempts.filter((attempt) => attempt.status === 'success');
|
|
801
|
+
const nonSuccess = attempts.filter((attempt) => attempt.status === 'failed' || attempt.status === 'empty' || attempt.status === 'skipped');
|
|
802
|
+
if (success.length > 0 && nonSuccess.length > 0) {
|
|
803
|
+
return 'partial';
|
|
804
|
+
}
|
|
805
|
+
if (success.length > 0) {
|
|
806
|
+
return 'supported';
|
|
807
|
+
}
|
|
808
|
+
if (!hasSchemaOrAnalysis) {
|
|
809
|
+
return 'unsupported';
|
|
810
|
+
}
|
|
811
|
+
return 'not_exportable';
|
|
812
|
+
}
|
|
813
|
+
supportReason(kind, status, attempts, sourceTracks = []) {
|
|
814
|
+
const section = this.instrumentSectionName(kind);
|
|
815
|
+
switch (status) {
|
|
816
|
+
case 'supported':
|
|
817
|
+
return `${section} data was exported and parsed.`;
|
|
818
|
+
case 'partial':
|
|
819
|
+
return `${section} data was parsed, but some schemas failed, were empty, or were skipped.`;
|
|
820
|
+
case 'not_exportable': {
|
|
821
|
+
const failedAttempt = attempts.find((attempt) => attempt.status === 'failed' && attempt.message);
|
|
822
|
+
if (failedAttempt?.message) {
|
|
823
|
+
return `xctrace exposed ${kind} schemas, but no usable rows were exported: ${failedAttempt.message}`;
|
|
824
|
+
}
|
|
825
|
+
if (sourceTracks.length > 0) {
|
|
826
|
+
return `${section} is visible in Instruments.app (${sourceTracks.join(', ')}), but xcrun did not expose an exportable ${kind} table schema. Open the trace in Instruments.app to inspect it.`;
|
|
827
|
+
}
|
|
828
|
+
return `xctrace exposed ${kind} schemas, but no usable rows were exported.`;
|
|
829
|
+
}
|
|
830
|
+
case 'unsupported':
|
|
831
|
+
return this.unsupportedReason(kind);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
unsupportedReason(kind) {
|
|
835
|
+
switch (kind) {
|
|
836
|
+
case 'memory':
|
|
837
|
+
return 'No Memory table schema was present in this trace TOC, so automated generic memory metrics are unavailable for this recording/template/platform. Memory is separate from Allocations and Leaks; check those sections or Instruments.app separately.';
|
|
838
|
+
case 'energy':
|
|
839
|
+
return 'No Energy / Power table schema was present in this trace TOC, so automated energy analysis is unavailable for this recording/template/platform. Power Profiler is mainly for iOS/iPadOS and may be absent from macOS full recordings.';
|
|
840
|
+
default: {
|
|
841
|
+
const section = this.instrumentSectionName(kind);
|
|
842
|
+
return `No ${section} table schema was present in this trace TOC, so automated ${section} analysis is unavailable for this recording/template/platform. This is not evidence that no issue exists.`;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
instrumentSectionName(kind) {
|
|
847
|
+
switch (kind) {
|
|
848
|
+
case 'time-profile':
|
|
849
|
+
return 'Time Profiler';
|
|
850
|
+
case 'memory':
|
|
851
|
+
return 'Memory';
|
|
852
|
+
case 'network':
|
|
853
|
+
return 'Network';
|
|
854
|
+
case 'energy':
|
|
855
|
+
return 'Energy';
|
|
856
|
+
case 'allocations':
|
|
857
|
+
return 'Allocations';
|
|
858
|
+
case 'leaks':
|
|
859
|
+
return 'Leaks';
|
|
860
|
+
case 'hangs':
|
|
861
|
+
return 'Hangs';
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
analyzeMemoryRows(rows, sourceSchemas) {
|
|
865
|
+
const metrics = [];
|
|
866
|
+
const findings = this.unexportableFinding(rows, 'Memory');
|
|
867
|
+
const peak = this.maxMetric(rows, [/peak.*memory/i, /peak/i]);
|
|
868
|
+
const resident = this.maxMetric(rows, [/resident/i]);
|
|
869
|
+
const dirty = this.maxMetric(rows, [/dirty/i]);
|
|
870
|
+
this.pushByteMetric(metrics, 'Peak Memory', peak);
|
|
871
|
+
this.pushByteMetric(metrics, 'Resident Memory', resident);
|
|
872
|
+
this.pushByteMetric(metrics, 'Dirty Memory', dirty);
|
|
873
|
+
if ((peak ?? resident ?? 0) > 512 * 1024 * 1024) {
|
|
874
|
+
findings.push({
|
|
875
|
+
severity: 'high',
|
|
876
|
+
title: 'High peak memory usage',
|
|
877
|
+
description: 'Peak memory exceeded 512 MB. Inspect retained objects, image caches, and large buffers.',
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
kind: 'memory',
|
|
882
|
+
title: 'Memory Analysis',
|
|
883
|
+
summary: peak
|
|
884
|
+
? `Peak memory was ${this.formatBytes(peak)}.`
|
|
885
|
+
: 'Memory data was found, but no peak memory metric was exportable.',
|
|
886
|
+
metrics,
|
|
887
|
+
findings,
|
|
888
|
+
sourceSchemas,
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
analyzeAllocationRows(rows, sourceSchemas) {
|
|
892
|
+
const metrics = [];
|
|
893
|
+
const findings = this.unexportableFinding(rows, 'Allocations');
|
|
894
|
+
const totalAllocations = this.maxMetric(rows, [/total.*alloc/i, /^allocations?$/i, /^count$/i]);
|
|
895
|
+
const totalBytes = this.maxMetric(rows, [/total.*bytes/i, /allocated.*bytes/i, /^bytes$/i]);
|
|
896
|
+
this.pushNumberMetric(metrics, 'Total Allocations', totalAllocations);
|
|
897
|
+
this.pushByteMetric(metrics, 'Total Allocated Bytes', totalBytes);
|
|
898
|
+
const top = this.topRowByBytes(rows);
|
|
899
|
+
if (top.label && top.bytes) {
|
|
900
|
+
metrics.push({
|
|
901
|
+
name: 'Top Allocation Site',
|
|
902
|
+
value: `${top.label} (${this.formatBytes(top.bytes)})`,
|
|
903
|
+
numericValue: top.bytes,
|
|
904
|
+
unit: 'bytes',
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
if ((totalBytes ?? top.bytes ?? 0) > 100 * 1024 * 1024) {
|
|
908
|
+
findings.push({
|
|
909
|
+
severity: 'medium',
|
|
910
|
+
title: 'High allocation volume',
|
|
911
|
+
description: 'Allocated bytes exceeded 100 MB. Look for allocation churn in hot paths and repeated object creation.',
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
return {
|
|
915
|
+
kind: 'allocations',
|
|
916
|
+
title: 'Allocations Analysis',
|
|
917
|
+
summary: totalAllocations
|
|
918
|
+
? `${Math.round(totalAllocations).toLocaleString()} allocations were recorded.`
|
|
919
|
+
: 'Allocation data was found.',
|
|
920
|
+
metrics,
|
|
921
|
+
findings,
|
|
922
|
+
sourceSchemas,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
analyzeLeakRows(rows, sourceSchemas) {
|
|
926
|
+
const metrics = [];
|
|
927
|
+
const findings = this.unexportableFinding(rows, 'Leaks');
|
|
928
|
+
const leaks = this.maxMetric(rows, [/^leaks?$/i, /leak.*count/i, /^count$/i]);
|
|
929
|
+
const leakedBytes = this.maxMetric(rows, [/leaked.*bytes/i, /leak.*size/i, /^bytes$/i]);
|
|
930
|
+
this.pushNumberMetric(metrics, 'Leaks', leaks);
|
|
931
|
+
this.pushByteMetric(metrics, 'Leaked Bytes', leakedBytes);
|
|
932
|
+
const top = this.topRowByBytes(rows);
|
|
933
|
+
if (top.label && top.bytes) {
|
|
934
|
+
metrics.push({
|
|
935
|
+
name: 'Top Leak Site',
|
|
936
|
+
value: `${top.label} (${this.formatBytes(top.bytes)})`,
|
|
937
|
+
numericValue: top.bytes,
|
|
938
|
+
unit: 'bytes',
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
if ((leaks ?? 0) > 0 || (leakedBytes ?? 0) > 0) {
|
|
942
|
+
findings.push({
|
|
943
|
+
severity: 'critical',
|
|
944
|
+
title: 'Leaks detected',
|
|
945
|
+
description: 'The trace contains leaked memory. Inspect the reported leak sites for missing releases or retain cycles.',
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
return {
|
|
949
|
+
kind: 'leaks',
|
|
950
|
+
title: 'Leaks Analysis',
|
|
951
|
+
summary: leaks ? `${Math.round(leaks)} leaks were detected.` : 'Leak data was found.',
|
|
952
|
+
metrics,
|
|
953
|
+
findings,
|
|
954
|
+
sourceSchemas,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
analyzeNetworkRows(rows, sourceSchemas) {
|
|
958
|
+
const findings = this.unexportableFinding(rows, 'Network');
|
|
959
|
+
const usableRows = rows.filter((row) => row.Exportable !== false);
|
|
960
|
+
const requests = usableRows.length;
|
|
961
|
+
let failedRequests = 0;
|
|
962
|
+
let transferredBytes = 0;
|
|
963
|
+
const hosts = new Map();
|
|
964
|
+
for (const row of usableRows) {
|
|
965
|
+
const status = this.firstMetric(row, [/status/i, /response.*code/i]);
|
|
966
|
+
if (status !== undefined && status >= 400) {
|
|
967
|
+
failedRequests++;
|
|
968
|
+
}
|
|
969
|
+
transferredBytes += this.sumMatchingMetrics(row, [/bytes/i, /body.*size/i]);
|
|
970
|
+
const url = this.firstString(row, [/url/i, /host/i, /domain/i]);
|
|
971
|
+
const host = this.hostFromURL(url);
|
|
972
|
+
if (host) {
|
|
973
|
+
hosts.set(host, (hosts.get(host) ?? 0) + 1);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const metrics = [
|
|
977
|
+
{ name: 'Requests', value: requests, numericValue: requests },
|
|
978
|
+
{ name: 'Failed Requests', value: failedRequests, numericValue: failedRequests },
|
|
979
|
+
{
|
|
980
|
+
name: 'Transferred Bytes',
|
|
981
|
+
value: this.formatBytes(transferredBytes),
|
|
982
|
+
numericValue: transferredBytes,
|
|
983
|
+
unit: 'bytes',
|
|
984
|
+
},
|
|
985
|
+
];
|
|
986
|
+
const topHost = this.topMapEntry(hosts);
|
|
987
|
+
if (topHost) {
|
|
988
|
+
metrics.push({ name: 'Top Host', value: `${topHost[0]} (${topHost[1]} requests)`, numericValue: topHost[1] });
|
|
989
|
+
}
|
|
990
|
+
if (failedRequests > 0) {
|
|
991
|
+
findings.push({
|
|
992
|
+
severity: 'medium',
|
|
993
|
+
title: 'Network failures detected',
|
|
994
|
+
description: `${failedRequests} request${failedRequests === 1 ? '' : 's'} returned an error status.`,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
return {
|
|
998
|
+
kind: 'network',
|
|
999
|
+
title: 'Network Analysis',
|
|
1000
|
+
summary: `${requests} request${requests === 1 ? '' : 's'}, ${failedRequests} failed.`,
|
|
1001
|
+
metrics,
|
|
1002
|
+
findings,
|
|
1003
|
+
sourceSchemas,
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
analyzeNetworkHAR(har, sourceSchemas) {
|
|
1007
|
+
const entries = Array.isArray(har?.log?.entries) ? har.log.entries : [];
|
|
1008
|
+
if (entries.length === 0) {
|
|
1009
|
+
return undefined;
|
|
1010
|
+
}
|
|
1011
|
+
const rows = entries.map((entry) => ({
|
|
1012
|
+
URL: entry?.request?.url ?? '',
|
|
1013
|
+
Status: Number(entry?.response?.status ?? 0),
|
|
1014
|
+
'Request Bytes': Number(entry?.request?.bodySize ?? 0),
|
|
1015
|
+
'Response Bytes': Number(entry?.response?.bodySize ?? 0),
|
|
1016
|
+
}));
|
|
1017
|
+
return this.analyzeNetworkRows(rows, sourceSchemas);
|
|
1018
|
+
}
|
|
1019
|
+
analyzeEnergyRows(rows, sourceSchemas) {
|
|
1020
|
+
const metrics = [];
|
|
1021
|
+
const findings = this.unexportableFinding(rows, 'Energy');
|
|
1022
|
+
const impact = this.maxMetric(rows, [/energy.*impact/i, /^impact$/i, /score/i]);
|
|
1023
|
+
const wakeups = this.maxMetric(rows, [/wakeups?/i]);
|
|
1024
|
+
const cpu = this.maxMetric(rows, [/cpu.*time/i, /^cpu$/i]);
|
|
1025
|
+
this.pushNumberMetric(metrics, 'Energy Impact', impact);
|
|
1026
|
+
this.pushNumberMetric(metrics, 'Wakeups', wakeups);
|
|
1027
|
+
this.pushNumberMetric(metrics, 'CPU Time', cpu, 'seconds');
|
|
1028
|
+
if ((impact ?? 0) >= 50) {
|
|
1029
|
+
findings.push({
|
|
1030
|
+
severity: 'high',
|
|
1031
|
+
title: 'High energy impact',
|
|
1032
|
+
description: 'Energy impact is elevated. Reduce background work, polling, wakeups, and expensive networking.',
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
return {
|
|
1036
|
+
kind: 'energy',
|
|
1037
|
+
title: 'Energy Analysis',
|
|
1038
|
+
summary: impact ? `Energy impact peaked at ${impact}.` : 'Energy data was found.',
|
|
1039
|
+
metrics,
|
|
1040
|
+
findings,
|
|
1041
|
+
sourceSchemas,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
extractSchemas(tocData) {
|
|
1045
|
+
const schemas = new Set();
|
|
1046
|
+
const visit = (node, depth = 0) => {
|
|
1047
|
+
if (!node || typeof node !== 'object') {
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (depth > MAX_XML_DEPTH) {
|
|
1051
|
+
throw new TraceParserError(`Trace TOC nesting exceeded the ${MAX_XML_DEPTH} level safety limit`);
|
|
1052
|
+
}
|
|
1053
|
+
if (typeof node['@_schema'] === 'string') {
|
|
1054
|
+
schemas.add(node['@_schema']);
|
|
1055
|
+
}
|
|
1056
|
+
for (const value of Object.values(node)) {
|
|
1057
|
+
if (Array.isArray(value)) {
|
|
1058
|
+
value.forEach((item) => visit(item, depth + 1));
|
|
1059
|
+
}
|
|
1060
|
+
else {
|
|
1061
|
+
visit(value, depth + 1);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
visit(tocData);
|
|
1066
|
+
return Array.from(schemas);
|
|
1067
|
+
}
|
|
1068
|
+
extractGuiTrackNamesByKind(tocData) {
|
|
1069
|
+
const namesByKind = {};
|
|
1070
|
+
const addName = (name) => {
|
|
1071
|
+
const trimmed = name.trim();
|
|
1072
|
+
if (!trimmed) {
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
for (const kind of INSTRUMENT_ORDER) {
|
|
1076
|
+
if (SCHEMA_PATTERNS[kind].some((pattern) => pattern.test(trimmed))) {
|
|
1077
|
+
namesByKind[kind] ??= new Set();
|
|
1078
|
+
namesByKind[kind].add(trimmed);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
const visit = (node, keyName, depth = 0) => {
|
|
1083
|
+
if (!node || typeof node !== 'object') {
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (depth > MAX_XML_DEPTH) {
|
|
1087
|
+
throw new TraceParserError(`Trace TOC nesting exceeded the ${MAX_XML_DEPTH} level safety limit`);
|
|
1088
|
+
}
|
|
1089
|
+
if (keyName === 'instrument' || keyName === 'track' || keyName === 'detail') {
|
|
1090
|
+
const name = this.nodeName(node);
|
|
1091
|
+
if (name) {
|
|
1092
|
+
addName(name);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
for (const [key, value] of Object.entries(node)) {
|
|
1096
|
+
if (key.startsWith('@_')) {
|
|
1097
|
+
continue;
|
|
1098
|
+
}
|
|
1099
|
+
for (const child of this.arrayOf(value)) {
|
|
1100
|
+
visit(child, key, depth + 1);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
visit(tocData);
|
|
1105
|
+
return Object.fromEntries(Object.entries(namesByKind).map(([kind, names]) => [kind, Array.from(names)]));
|
|
1106
|
+
}
|
|
1107
|
+
extractUserProcessNames(tocData) {
|
|
1108
|
+
const names = new Set();
|
|
1109
|
+
const visit = (node, keyName, attachedTarget = false, depth = 0) => {
|
|
1110
|
+
if (!node || typeof node !== 'object') {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (depth > MAX_XML_DEPTH) {
|
|
1114
|
+
throw new TraceParserError(`Trace TOC nesting exceeded the ${MAX_XML_DEPTH} level safety limit`);
|
|
1115
|
+
}
|
|
1116
|
+
const currentAttached = attachedTarget || this.isAttachedTargetNode(keyName, node);
|
|
1117
|
+
if (keyName === 'process') {
|
|
1118
|
+
const path = this.processPath(node);
|
|
1119
|
+
const name = this.processNameFromNode(node, path);
|
|
1120
|
+
if (name && (currentAttached || !this.isSystemProcessPath(path))) {
|
|
1121
|
+
names.add(name);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
for (const [key, value] of Object.entries(node)) {
|
|
1125
|
+
if (key.startsWith('@_')) {
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
for (const child of this.arrayOf(value)) {
|
|
1129
|
+
visit(child, key, currentAttached, depth + 1);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
visit(tocData);
|
|
1134
|
+
return Array.from(names);
|
|
1135
|
+
}
|
|
1136
|
+
isAttachedTargetNode(keyName, node) {
|
|
1137
|
+
if (keyName !== 'target') {
|
|
1138
|
+
return false;
|
|
1139
|
+
}
|
|
1140
|
+
const type = node?.['@_type'] ?? node?.type;
|
|
1141
|
+
return typeof type === 'string' && type.toLowerCase() === 'attached';
|
|
1142
|
+
}
|
|
1143
|
+
processPath(node) {
|
|
1144
|
+
const path = node?.['@_path'] ?? node?.path ?? node?.executablePath;
|
|
1145
|
+
return typeof path === 'string' && path.length > 0 ? path : undefined;
|
|
1146
|
+
}
|
|
1147
|
+
processNameFromNode(node, path) {
|
|
1148
|
+
const rawName = this.nodeName(node) ?? this.readFmtField(node);
|
|
1149
|
+
if (rawName) {
|
|
1150
|
+
return rawName;
|
|
1151
|
+
}
|
|
1152
|
+
if (!path) {
|
|
1153
|
+
return undefined;
|
|
1154
|
+
}
|
|
1155
|
+
return basename(path).replace(/\.(app|xpc|appex|framework)$/i, '') || undefined;
|
|
1156
|
+
}
|
|
1157
|
+
isSystemProcessPath(path) {
|
|
1158
|
+
if (!path) {
|
|
1159
|
+
return false;
|
|
1160
|
+
}
|
|
1161
|
+
return path.startsWith('/System/') ||
|
|
1162
|
+
path.startsWith('/usr/lib/') ||
|
|
1163
|
+
path.startsWith('/usr/libexec/');
|
|
1164
|
+
}
|
|
1165
|
+
extractTableDescriptors(tocData) {
|
|
1166
|
+
const descriptors = [];
|
|
1167
|
+
const seen = new Set();
|
|
1168
|
+
const runs = this.arrayOf(tocData['trace-toc']?.run);
|
|
1169
|
+
const addDescriptor = (node, runNumber, xpath, trackName, detailName) => {
|
|
1170
|
+
const schema = node?.['@_schema'];
|
|
1171
|
+
if (typeof schema !== 'string' || !schema) {
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
const key = `${runNumber}:${schema}:${xpath}`;
|
|
1175
|
+
if (seen.has(key)) {
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
seen.add(key);
|
|
1179
|
+
descriptors.push({
|
|
1180
|
+
runNumber,
|
|
1181
|
+
schema,
|
|
1182
|
+
xpath,
|
|
1183
|
+
trackName,
|
|
1184
|
+
detailName,
|
|
1185
|
+
});
|
|
1186
|
+
};
|
|
1187
|
+
const visit = (node, runNumber, path, trackName, detailName, depth = 0) => {
|
|
1188
|
+
if (!node || typeof node !== 'object') {
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
if (depth > MAX_XML_DEPTH) {
|
|
1192
|
+
throw new TraceParserError(`Trace TOC nesting exceeded the ${MAX_XML_DEPTH} level safety limit`);
|
|
1193
|
+
}
|
|
1194
|
+
addDescriptor(node, runNumber, path, trackName, detailName);
|
|
1195
|
+
for (const [key, value] of Object.entries(node)) {
|
|
1196
|
+
if (key.startsWith('@_')) {
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
for (const child of this.arrayOf(value)) {
|
|
1200
|
+
if (!child || typeof child !== 'object') {
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
const childTrackName = key === 'track'
|
|
1204
|
+
? this.nodeName(child) ?? trackName
|
|
1205
|
+
: trackName;
|
|
1206
|
+
const childDetailName = key === 'detail'
|
|
1207
|
+
? this.nodeName(child) ?? detailName
|
|
1208
|
+
: detailName;
|
|
1209
|
+
visit(child, runNumber, `${path}/${this.xpathSegment(key, child)}`, childTrackName, childDetailName, depth + 1);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
};
|
|
1213
|
+
runs.forEach((run, index) => {
|
|
1214
|
+
const runNumber = this.parseNumeric(run?.['@_number']) ?? index + 1;
|
|
1215
|
+
visit(run, runNumber, `/trace-toc/run[@number="${runNumber}"]`);
|
|
1216
|
+
});
|
|
1217
|
+
return descriptors;
|
|
1218
|
+
}
|
|
1219
|
+
unexportableFinding(rows, title) {
|
|
1220
|
+
if (rows.length === 0 || rows.every((row) => row.Exportable === false)) {
|
|
1221
|
+
return [
|
|
1222
|
+
{
|
|
1223
|
+
severity: 'low',
|
|
1224
|
+
title: `No exportable ${title} rows`,
|
|
1225
|
+
description: `xctrace exposed a ${title.toLowerCase()} table schema, but did not export usable rows for this trace.`,
|
|
1226
|
+
},
|
|
1227
|
+
];
|
|
1228
|
+
}
|
|
1229
|
+
return [];
|
|
1230
|
+
}
|
|
1231
|
+
matchSchemasForKind(schemas, kind) {
|
|
1232
|
+
const patterns = SCHEMA_PATTERNS[kind];
|
|
1233
|
+
return schemas.filter((schema) => patterns.some((pattern) => pattern.test(schema)));
|
|
1234
|
+
}
|
|
1235
|
+
async exportRowsForSchemas(tracePath, schemas, tableDescriptors, kind) {
|
|
1236
|
+
const rows = [];
|
|
1237
|
+
for (const schema of schemas) {
|
|
1238
|
+
const descriptor = tableDescriptors.find((table) => table.schema === schema);
|
|
1239
|
+
const xpath = descriptor?.xpath;
|
|
1240
|
+
try {
|
|
1241
|
+
const xml = xpath && this.exporter.exportXPath
|
|
1242
|
+
? await this.exporter.exportXPath(tracePath, xpath)
|
|
1243
|
+
: await this.exporter.exportTable(tracePath, schema, descriptor?.runNumber);
|
|
1244
|
+
const parsedRows = this.parseTableRows(xml);
|
|
1245
|
+
this.exportAttempts.push({
|
|
1246
|
+
kind,
|
|
1247
|
+
status: parsedRows.length > 0 ? 'success' : 'empty',
|
|
1248
|
+
schema,
|
|
1249
|
+
xpath,
|
|
1250
|
+
});
|
|
1251
|
+
rows.push(...parsedRows);
|
|
1252
|
+
}
|
|
1253
|
+
catch (error) {
|
|
1254
|
+
this.exportAttempts.push({
|
|
1255
|
+
kind,
|
|
1256
|
+
status: 'failed',
|
|
1257
|
+
schema,
|
|
1258
|
+
xpath,
|
|
1259
|
+
message: error.message,
|
|
1260
|
+
});
|
|
1261
|
+
rows.push({
|
|
1262
|
+
Schema: schema,
|
|
1263
|
+
Exportable: false,
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
return rows;
|
|
1268
|
+
}
|
|
1269
|
+
networkSchemasSafeForRawExport(schemas) {
|
|
1270
|
+
return schemas.filter((schema) => !NETWORK_RAW_EXPORT_DENY_PATTERNS.some((pattern) => pattern.test(schema)));
|
|
1271
|
+
}
|
|
1272
|
+
unexportedRowsForSchemas(schemas) {
|
|
1273
|
+
return schemas.map((schema) => ({
|
|
1274
|
+
Schema: schema,
|
|
1275
|
+
Exportable: false,
|
|
1276
|
+
}));
|
|
1277
|
+
}
|
|
1278
|
+
parseTableRows(xmlData) {
|
|
1279
|
+
if (!xmlData || !xmlData.trim()) {
|
|
1280
|
+
return [];
|
|
1281
|
+
}
|
|
1282
|
+
const parsed = this.parseXml(xmlData, 'instrument table');
|
|
1283
|
+
const table = parsed.table ?? parsed['trace-query-result']?.node ?? this.findFirstKey(parsed, 'table');
|
|
1284
|
+
if (!table) {
|
|
1285
|
+
return [];
|
|
1286
|
+
}
|
|
1287
|
+
return this.limitRows(this.rowsFromTableNode(table), 'instrument table')
|
|
1288
|
+
.map((row) => this.flattenRow(row));
|
|
1289
|
+
}
|
|
1290
|
+
parseXml(xmlData, label) {
|
|
1291
|
+
this.assertInputSize(xmlData, MAX_XML_EXPORT_BYTES, label);
|
|
1292
|
+
return this.xmlParser.parse(xmlData);
|
|
1293
|
+
}
|
|
1294
|
+
assertInputSize(value, maxBytes, label) {
|
|
1295
|
+
const size = Buffer.byteLength(value, 'utf8');
|
|
1296
|
+
if (size > maxBytes) {
|
|
1297
|
+
throw new TraceParserError(`${label} exceeded the ${Math.round(maxBytes / 1024 / 1024)} MB safety limit`);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
limitRows(rows, label) {
|
|
1301
|
+
if (rows.length > MAX_TABLE_ROWS) {
|
|
1302
|
+
throw new TraceParserError(`${label} exported ${rows.length} rows, exceeding the ${MAX_TABLE_ROWS} row safety limit`);
|
|
1303
|
+
}
|
|
1304
|
+
return rows;
|
|
1305
|
+
}
|
|
1306
|
+
rowsFromTableNode(table) {
|
|
1307
|
+
const nodes = Array.isArray(table) ? table : [table];
|
|
1308
|
+
return nodes.flatMap((node) => {
|
|
1309
|
+
if (node?.row) {
|
|
1310
|
+
return Array.isArray(node.row) ? node.row : [node.row];
|
|
1311
|
+
}
|
|
1312
|
+
if (node?.table) {
|
|
1313
|
+
return this.rowsFromTableNode(node.table);
|
|
1314
|
+
}
|
|
1315
|
+
return [];
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
flattenRow(row) {
|
|
1319
|
+
const output = {};
|
|
1320
|
+
const write = (key, value) => {
|
|
1321
|
+
if (!key || value === undefined || value === null || value === '') {
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
output[this.cleanKey(key)] = this.parseScalar(value);
|
|
1325
|
+
};
|
|
1326
|
+
const visitCell = (cell, fallbackKey) => {
|
|
1327
|
+
if (cell === undefined || cell === null) {
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
if (typeof cell !== 'object') {
|
|
1331
|
+
write(fallbackKey, cell);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
const name = cell['@_name'] ?? cell['@_key'] ?? cell['@_title'] ?? cell.name ?? cell.key ?? fallbackKey;
|
|
1335
|
+
const value = cell['@_value'] ?? cell.value ?? cell['#text'] ?? cell.text;
|
|
1336
|
+
if (value !== undefined) {
|
|
1337
|
+
write(String(name), value);
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
for (const [childKey, childValue] of Object.entries(cell)) {
|
|
1341
|
+
if (childKey.startsWith('@_')) {
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
this.writeNestedValue(write, childKey, childValue);
|
|
1345
|
+
}
|
|
1346
|
+
};
|
|
1347
|
+
for (const [key, value] of Object.entries(row ?? {})) {
|
|
1348
|
+
if (key.startsWith('@_')) {
|
|
1349
|
+
write(key.slice(2), value);
|
|
1350
|
+
}
|
|
1351
|
+
else if (['column', 'cell', 'col', 'data'].includes(key)) {
|
|
1352
|
+
const cells = Array.isArray(value) ? value : [value];
|
|
1353
|
+
cells.forEach((cell) => visitCell(cell));
|
|
1354
|
+
}
|
|
1355
|
+
else {
|
|
1356
|
+
this.writeNestedValue(write, key, value);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return output;
|
|
1360
|
+
}
|
|
1361
|
+
writeNestedValue(write, key, value) {
|
|
1362
|
+
if (Array.isArray(value)) {
|
|
1363
|
+
value.forEach((item) => {
|
|
1364
|
+
if (item && typeof item === 'object') {
|
|
1365
|
+
const name = item['@_name'] ?? item['@_key'] ?? item.name ?? key;
|
|
1366
|
+
const nested = item['@_value'] ?? item.value ?? item['#text'] ?? item.text;
|
|
1367
|
+
write(String(name), nested);
|
|
1368
|
+
}
|
|
1369
|
+
else {
|
|
1370
|
+
write(key, item);
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
if (value && typeof value === 'object') {
|
|
1376
|
+
const name = value['@_name'] ?? value['@_key'] ?? value.name ?? key;
|
|
1377
|
+
const nested = value['@_value'] ?? value.value ?? value['#text'] ?? value.text;
|
|
1378
|
+
if (nested !== undefined) {
|
|
1379
|
+
write(String(name), nested);
|
|
1380
|
+
}
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
write(key, value);
|
|
1384
|
+
}
|
|
1385
|
+
findFirstKey(node, key) {
|
|
1386
|
+
return this.findFirstKeyAtDepth(node, key, 0);
|
|
1387
|
+
}
|
|
1388
|
+
findFirstKeyAtDepth(node, key, depth) {
|
|
1389
|
+
if (!node || typeof node !== 'object') {
|
|
1390
|
+
return undefined;
|
|
1391
|
+
}
|
|
1392
|
+
if (depth > MAX_XML_DEPTH) {
|
|
1393
|
+
throw new TraceParserError(`XML nesting exceeded the ${MAX_XML_DEPTH} level safety limit`);
|
|
1394
|
+
}
|
|
1395
|
+
if (node[key]) {
|
|
1396
|
+
return node[key];
|
|
1397
|
+
}
|
|
1398
|
+
for (const value of Object.values(node)) {
|
|
1399
|
+
const found = this.findFirstKeyAtDepth(value, key, depth + 1);
|
|
1400
|
+
if (found) {
|
|
1401
|
+
return found;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
return undefined;
|
|
1405
|
+
}
|
|
1406
|
+
cleanKey(key) {
|
|
1407
|
+
return key.replace(/^@_/, '').trim();
|
|
1408
|
+
}
|
|
1409
|
+
arrayOf(value) {
|
|
1410
|
+
if (value === undefined || value === null) {
|
|
1411
|
+
return [];
|
|
1412
|
+
}
|
|
1413
|
+
return Array.isArray(value) ? value : [value];
|
|
1414
|
+
}
|
|
1415
|
+
nodeName(node) {
|
|
1416
|
+
const name = node?.['@_name'] ?? node?.name ?? node?.['@_title'] ?? node?.title;
|
|
1417
|
+
return typeof name === 'string' && name ? name : undefined;
|
|
1418
|
+
}
|
|
1419
|
+
xpathSegment(key, node) {
|
|
1420
|
+
const elementName = this.safeXPathElementName(key);
|
|
1421
|
+
const schema = node?.['@_schema'];
|
|
1422
|
+
if (typeof schema === 'string' && schema) {
|
|
1423
|
+
return `${elementName}[@schema=${this.xpathStringLiteral(schema)}]`;
|
|
1424
|
+
}
|
|
1425
|
+
const name = this.nodeName(node);
|
|
1426
|
+
if (name) {
|
|
1427
|
+
return `${elementName}[@name=${this.xpathStringLiteral(name)}]`;
|
|
1428
|
+
}
|
|
1429
|
+
return elementName;
|
|
1430
|
+
}
|
|
1431
|
+
safeXPathElementName(key) {
|
|
1432
|
+
if (/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(key)) {
|
|
1433
|
+
return key;
|
|
1434
|
+
}
|
|
1435
|
+
throw new TraceParserError(`Unsafe XML element name in trace TOC: ${key}`);
|
|
1436
|
+
}
|
|
1437
|
+
xpathStringLiteral(value) {
|
|
1438
|
+
if (!value.includes('"')) {
|
|
1439
|
+
return `"${value}"`;
|
|
1440
|
+
}
|
|
1441
|
+
if (!value.includes("'")) {
|
|
1442
|
+
return `'${value}'`;
|
|
1443
|
+
}
|
|
1444
|
+
const parts = value.split("'").flatMap((part, index) => index === 0 ? [`'${part}'`] : [`"'"`, `'${part}'`]);
|
|
1445
|
+
return `concat(${parts.join(', ')})`;
|
|
1446
|
+
}
|
|
1447
|
+
parseScalar(value) {
|
|
1448
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
1449
|
+
return value;
|
|
1450
|
+
}
|
|
1451
|
+
const stringValue = String(value).trim();
|
|
1452
|
+
const numeric = this.parseNumeric(stringValue);
|
|
1453
|
+
return numeric ?? stringValue;
|
|
1454
|
+
}
|
|
1455
|
+
parseNumeric(value) {
|
|
1456
|
+
if (typeof value === 'number') {
|
|
1457
|
+
return Number.isFinite(value) ? value : undefined;
|
|
1458
|
+
}
|
|
1459
|
+
if (typeof value !== 'string') {
|
|
1460
|
+
return undefined;
|
|
1461
|
+
}
|
|
1462
|
+
const normalized = value.trim().replace(/,/g, '');
|
|
1463
|
+
const match = normalized.match(/^(-?\d+(?:\.\d+)?)(?:\s*([a-zA-Z%][a-zA-Z%/]*))?$/);
|
|
1464
|
+
if (!match) {
|
|
1465
|
+
return undefined;
|
|
1466
|
+
}
|
|
1467
|
+
const number = Number(match[1]);
|
|
1468
|
+
if (!Number.isFinite(number)) {
|
|
1469
|
+
return undefined;
|
|
1470
|
+
}
|
|
1471
|
+
const unit = match[2]?.toLowerCase();
|
|
1472
|
+
if (!unit) {
|
|
1473
|
+
return number;
|
|
1474
|
+
}
|
|
1475
|
+
const byteMultiplier = BYTE_UNIT_MULTIPLIERS[unit];
|
|
1476
|
+
if (byteMultiplier !== undefined) {
|
|
1477
|
+
return number * byteMultiplier;
|
|
1478
|
+
}
|
|
1479
|
+
if (!NUMERIC_UNITS.has(unit)) {
|
|
1480
|
+
return undefined;
|
|
1481
|
+
}
|
|
1482
|
+
return number;
|
|
1483
|
+
}
|
|
1484
|
+
maxMetric(rows, patterns) {
|
|
1485
|
+
const values = rows.flatMap((row) => {
|
|
1486
|
+
const value = this.firstMetric(row, patterns);
|
|
1487
|
+
return value === undefined ? [] : [value];
|
|
1488
|
+
});
|
|
1489
|
+
return values.length > 0 ? Math.max(...values) : undefined;
|
|
1490
|
+
}
|
|
1491
|
+
firstMetric(row, patterns) {
|
|
1492
|
+
for (const [key, value] of Object.entries(row)) {
|
|
1493
|
+
if (patterns.some((pattern) => pattern.test(key))) {
|
|
1494
|
+
const numeric = this.parseNumeric(value);
|
|
1495
|
+
if (numeric !== undefined) {
|
|
1496
|
+
return numeric;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
return undefined;
|
|
1501
|
+
}
|
|
1502
|
+
firstString(row, patterns) {
|
|
1503
|
+
for (const [key, value] of Object.entries(row)) {
|
|
1504
|
+
if (patterns.some((pattern) => pattern.test(key)) && typeof value === 'string') {
|
|
1505
|
+
return value;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
return undefined;
|
|
1509
|
+
}
|
|
1510
|
+
sumMatchingMetrics(row, patterns) {
|
|
1511
|
+
return Object.entries(row).reduce((sum, [key, value]) => {
|
|
1512
|
+
if (!patterns.some((pattern) => pattern.test(key))) {
|
|
1513
|
+
return sum;
|
|
1514
|
+
}
|
|
1515
|
+
return sum + (this.parseNumeric(value) ?? 0);
|
|
1516
|
+
}, 0);
|
|
1517
|
+
}
|
|
1518
|
+
pushByteMetric(metrics, name, bytes) {
|
|
1519
|
+
if (bytes === undefined) {
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
metrics.push({
|
|
1523
|
+
name,
|
|
1524
|
+
value: this.formatBytes(bytes),
|
|
1525
|
+
numericValue: bytes,
|
|
1526
|
+
unit: 'bytes',
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
pushNumberMetric(metrics, name, value, unit) {
|
|
1530
|
+
if (value === undefined) {
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
metrics.push({
|
|
1534
|
+
name,
|
|
1535
|
+
value,
|
|
1536
|
+
numericValue: value,
|
|
1537
|
+
unit,
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
topRowByBytes(rows) {
|
|
1541
|
+
let best = {};
|
|
1542
|
+
for (const row of rows) {
|
|
1543
|
+
const bytes = this.firstMetric(row, [/bytes/i, /size/i]);
|
|
1544
|
+
if (bytes === undefined || bytes <= (best.bytes ?? 0)) {
|
|
1545
|
+
continue;
|
|
1546
|
+
}
|
|
1547
|
+
const label = this.firstString(row, [/type/i, /call.*site/i, /symbol/i, /name/i]);
|
|
1548
|
+
best = { label, bytes };
|
|
1549
|
+
}
|
|
1550
|
+
return best;
|
|
1551
|
+
}
|
|
1552
|
+
topMapEntry(map) {
|
|
1553
|
+
return Array.from(map.entries()).sort((a, b) => b[1] - a[1])[0];
|
|
1554
|
+
}
|
|
1555
|
+
hostFromURL(value) {
|
|
1556
|
+
if (!value) {
|
|
1557
|
+
return undefined;
|
|
1558
|
+
}
|
|
1559
|
+
try {
|
|
1560
|
+
return new URL(value).host;
|
|
1561
|
+
}
|
|
1562
|
+
catch {
|
|
1563
|
+
return value.includes('.') ? value : undefined;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
formatBytes(bytes) {
|
|
1567
|
+
const abs = Math.abs(bytes);
|
|
1568
|
+
if (abs >= 1024 * 1024 * 1024) {
|
|
1569
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
|
1570
|
+
}
|
|
1571
|
+
if (abs >= 1024 * 1024) {
|
|
1572
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
1573
|
+
}
|
|
1574
|
+
if (abs >= 1024) {
|
|
1575
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1576
|
+
}
|
|
1577
|
+
return `${bytes} B`;
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* Parse a single sample row from XML
|
|
1581
|
+
*/
|
|
1582
|
+
parseSampleRow(row) {
|
|
1583
|
+
try {
|
|
1584
|
+
const timestamp = this.parseSampleTimestamp(row);
|
|
1585
|
+
const threadId = this.parseSampleThreadId(row);
|
|
1586
|
+
const weight = this.parseSampleWeight(row);
|
|
1587
|
+
// Parse backtrace
|
|
1588
|
+
const backtrace = [];
|
|
1589
|
+
const backtraceNode = row.backtrace ?? row['tagged-backtrace']?.backtrace;
|
|
1590
|
+
if (backtraceNode) {
|
|
1591
|
+
const frames = Array.isArray(backtraceNode.frame)
|
|
1592
|
+
? backtraceNode.frame
|
|
1593
|
+
: [backtraceNode.frame];
|
|
1594
|
+
for (const frame of frames) {
|
|
1595
|
+
if (frame && frame['@_name']) {
|
|
1596
|
+
backtrace.push(String(frame['@_name']));
|
|
1597
|
+
}
|
|
1598
|
+
else if (frame && typeof frame === 'string') {
|
|
1599
|
+
backtrace.push(frame);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return {
|
|
1604
|
+
timestamp,
|
|
1605
|
+
threadId,
|
|
1606
|
+
backtrace,
|
|
1607
|
+
weight,
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
catch {
|
|
1611
|
+
return null;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
parseSampleTimestamp(row) {
|
|
1615
|
+
const value = row['@_time'] ?? row.time ?? row['sample-time'];
|
|
1616
|
+
const raw = this.nodeScalar(value);
|
|
1617
|
+
const numeric = this.parseNumeric(raw);
|
|
1618
|
+
if (numeric === undefined) {
|
|
1619
|
+
return 0;
|
|
1620
|
+
}
|
|
1621
|
+
// xctrace sample-time text values are nanoseconds; older fixtures use ms.
|
|
1622
|
+
return numeric > 100000 ? numeric / 1000000 : numeric;
|
|
1623
|
+
}
|
|
1624
|
+
parseSampleThreadId(row) {
|
|
1625
|
+
const value = row['@_thread'] ?? row.thread?.tid ?? row.thread;
|
|
1626
|
+
return this.parseNumeric(this.nodeScalar(value)) ?? 0;
|
|
1627
|
+
}
|
|
1628
|
+
parseSampleWeight(row) {
|
|
1629
|
+
const value = row['@_weight'] ?? row.weight;
|
|
1630
|
+
if (value && typeof value === 'object' && typeof value['@_fmt'] === 'string') {
|
|
1631
|
+
return this.parseDuration(value['@_fmt']);
|
|
1632
|
+
}
|
|
1633
|
+
const numeric = this.parseNumeric(this.nodeScalar(value));
|
|
1634
|
+
if (numeric === undefined) {
|
|
1635
|
+
return 1;
|
|
1636
|
+
}
|
|
1637
|
+
// xctrace weight text values are nanoseconds; older fixtures use ms.
|
|
1638
|
+
return numeric > 100000 ? numeric / 1000000 : numeric;
|
|
1639
|
+
}
|
|
1640
|
+
nodeScalar(value) {
|
|
1641
|
+
if (value === undefined || value === null) {
|
|
1642
|
+
return undefined;
|
|
1643
|
+
}
|
|
1644
|
+
if (typeof value !== 'object') {
|
|
1645
|
+
return value;
|
|
1646
|
+
}
|
|
1647
|
+
return value['#text'] ?? value['@_value'] ?? value.value ?? value.text ?? value['@_fmt'];
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Aggregate function profiles from samples
|
|
1651
|
+
*/
|
|
1652
|
+
aggregateFunctionProfiles(sample, functionMap) {
|
|
1653
|
+
// Process each function in the backtrace
|
|
1654
|
+
for (let i = 0; i < sample.backtrace.length; i++) {
|
|
1655
|
+
const funcName = sample.backtrace[i];
|
|
1656
|
+
if (typeof funcName !== 'string' || funcName.trim() === '') {
|
|
1657
|
+
continue;
|
|
1658
|
+
}
|
|
1659
|
+
// Parse function name and module
|
|
1660
|
+
const { name, module } = this.parseFunctionName(funcName);
|
|
1661
|
+
const key = `${module || 'unknown'}::${name}`;
|
|
1662
|
+
let profile = functionMap.get(key);
|
|
1663
|
+
if (!profile) {
|
|
1664
|
+
profile = {
|
|
1665
|
+
name,
|
|
1666
|
+
module,
|
|
1667
|
+
totalTime: 0,
|
|
1668
|
+
selfTime: 0,
|
|
1669
|
+
callCount: 0,
|
|
1670
|
+
percentage: 0,
|
|
1671
|
+
};
|
|
1672
|
+
functionMap.set(key, profile);
|
|
1673
|
+
}
|
|
1674
|
+
// Add sample weight to total time
|
|
1675
|
+
profile.totalTime += sample.weight;
|
|
1676
|
+
profile.callCount += 1;
|
|
1677
|
+
// Self time is only for leaf nodes (last in backtrace)
|
|
1678
|
+
if (i === sample.backtrace.length - 1) {
|
|
1679
|
+
profile.selfTime += sample.weight;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Parse function name to extract module and function
|
|
1685
|
+
*/
|
|
1686
|
+
parseFunctionName(fullName) {
|
|
1687
|
+
// Format can be: "ModuleName`functionName" or just "functionName"
|
|
1688
|
+
const backtickIndex = fullName.indexOf('`');
|
|
1689
|
+
if (backtickIndex > 0) {
|
|
1690
|
+
return {
|
|
1691
|
+
module: fullName.substring(0, backtickIndex),
|
|
1692
|
+
name: fullName.substring(backtickIndex + 1),
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
// Check for other separators
|
|
1696
|
+
const colonIndex = fullName.indexOf('::');
|
|
1697
|
+
if (colonIndex > 0) {
|
|
1698
|
+
return {
|
|
1699
|
+
module: fullName.substring(0, colonIndex),
|
|
1700
|
+
name: fullName.substring(colonIndex + 2),
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
return { name: fullName };
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Parse duration string to milliseconds
|
|
1707
|
+
*/
|
|
1708
|
+
parseDuration(duration) {
|
|
1709
|
+
if (typeof duration === 'number') {
|
|
1710
|
+
return duration;
|
|
1711
|
+
}
|
|
1712
|
+
// Try to parse various duration formats
|
|
1713
|
+
const match = duration.match(/(\d+(?:\.\d+)?)\s*(ms|s|m|h)?/);
|
|
1714
|
+
if (match) {
|
|
1715
|
+
const value = parseFloat(match[1]);
|
|
1716
|
+
const unit = match[2] || 's';
|
|
1717
|
+
switch (unit) {
|
|
1718
|
+
case 'ms':
|
|
1719
|
+
return value;
|
|
1720
|
+
case 's':
|
|
1721
|
+
return value * 1000;
|
|
1722
|
+
case 'm':
|
|
1723
|
+
return value * 60 * 1000;
|
|
1724
|
+
case 'h':
|
|
1725
|
+
return value * 60 * 60 * 1000;
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
return 0;
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Convenience function to parse a trace
|
|
1733
|
+
*/
|
|
1734
|
+
export async function parseTrace(tracePath, options) {
|
|
1735
|
+
const parser = new TraceParser();
|
|
1736
|
+
return parser.parseTrace(tracePath, options);
|
|
1737
|
+
}
|
|
1738
|
+
//# sourceMappingURL=trace-parser.js.map
|