feat(performance): chunked trace buffer parser for large recordings (#2721)

> [!NOTE]
> This PR is marked as **WIP (Work in Progress)** / Draft to gather
feedback and extra opinions on the streaming / chunked parsing strategy
for large performance traces.

## Problem Statement

When recording performance traces on busy sites or over long recording
durations, the resulting trace buffer often exceeds 512 MB (and can
reach 1 GB+).

Currently, `parseRawTraceBuffer` decodes the entire raw buffer into a
single string via `new TextDecoder().decode(buffer)` and parses it with
`JSON.parse(asString)`. In V8 (64-bit), strings are strictly limited to
$2^{29} - 24$ characters (~512 MB). Any trace exceeding ~512 MB throws a
fatal error:
```
RangeError: Cannot create a string longer than 0x1fffffe8 characters
```
This prevents the MCP server from parsing and analyzing large traces
even when sufficient heap memory is available.

## Proposed Solution: Chunked Trace Buffer Scanner

This change introduces `ChunkedTraceParser.ts` to extract trace events
directly from the raw binary buffer in bounded batches without
materializing the full trace JSON into a single string:

1. **Byte-Level Delimiter & Escape Scanning**:
- Scans the `Uint8Array` directly using ASCII byte codes (`0x7B` `{`,
`0x7D` `}`, `0x22` `"`, `0x5C` `\`).
- Tracks brace nesting depth while properly handling escaped quotes
within strings.
- Supports both bare JSON arrays (`[ {...}, ... ]`) and top-level object
envelopes (`{ "traceEvents": [ ... ], "metadata": { ... } }`).

2. **Batched Native `JSON.parse`**:
- Accumulates slices of events into bounded batches (default: 5,000
events) wrapped in `[` and `]`.
- Parses each batch using native V8 `JSON.parse`, keeping intermediate
string allocations small (< 5 MB) and short-lived.
- Pushes parsed events iteratively to avoid V8 function call argument
stack limits.

3. **Metadata Preservation**:
- Extracts top-level metadata from object envelopes (such as
`cpuThrottling` and `networkThrottling`) and merges it with caller
options.

4. **Strict Structural Validation**:
- Enforces valid structural tokens (closing brackets, braces, colons,
commas) and prevents silent truncation on incomplete buffers or
unexpected EOF.

## Testing

- Unit tests in `tests/trace-processing/ChunkedTraceParser.test.ts`
covering:
  - Both top-level array and object formats.
- Custom batch sizes and large batches exceeding function call argument
limits (70,000+ events).
- Malformed inputs: truncated arrays, unterminated strings, unquoted
keys, trailing characters.
  - Multibyte UTF-8 characters split across byte boundaries.
- Full regression tests against existing trace fixtures
(`basic-trace.json.gz`, `web-dev-with-commit.json.gz`).
This commit is contained in:
Jack Franklin
2026-09-17 13:21:44 +00:00
committed by GitHub
parent 55fbc576f7
commit 23b9a48001
6 changed files with 976 additions and 25 deletions
+470
View File
@@ -0,0 +1,470 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Provides memory-efficient chunked parsing for Chrome DevTools trace files.
* Large performance traces can exceed V8 string length limits and cause heap exhaustion.
* This parser scans raw byte buffers, finds event boundaries, and parses events in batches.
*/
import type {DevTools} from '../third_party/index.js';
/**
* Represents the output of a parsed trace buffer.
*/
export interface ParsedTraceBuffer {
/** The collection of parsed trace events extracted from the buffer. */
events: DevTools.TraceEngine.Types.Events.Event[];
/** Optional file metadata extracted from the trace object container. */
metadata?: DevTools.TraceEngine.Types.File.MetaData;
}
/**
* Configuration options for trace buffer parsing.
*/
export interface ParseTraceBufferOptions {
/**
* The maximum number of trace events to decode and parse in a single batch.
* Lower values reduce memory spikes during string decoding.
* Higher values decrease the total number of JSON.parse calls.
* Non-positive or non-finite values fall back to the default.
* @defaultValue 10_000
*/
eventsPerBatch?: number;
/**
* The maximum byte size of raw trace event data to decode in a single batch.
* Flushes batches before V8 string length limits are approached.
* Non-positive or non-finite values fall back to the default.
* @defaultValue 33_554_432 (32 MB)
*/
maxBatchBytes?: number;
}
interface BatchConfig {
eventsPerBatch: number;
maxBatchBytes: number;
decoder: TextDecoder;
}
const DEFAULT_EVENTS_PER_BATCH = 10_000;
const DEFAULT_MAX_BATCH_BYTES = 32 * 1024 * 1024;
// ASCII character byte constants used by the state machine.
// All values are strictly less than 0x80 (standard ASCII).
// In UTF-8 encoding, multi-byte code units only contain bytes from 0x80 through 0xFF.
// Therefore, scanning for these byte values will never match bytes inside multi-byte characters.
const BYTE_QUOTE = 0x22;
const BYTE_BACKSLASH = 0x5c;
const BYTE_OPEN_BRACE = 0x7b;
const BYTE_CLOSE_BRACE = 0x7d;
const BYTE_OPEN_BRACKET = 0x5b;
const BYTE_CLOSE_BRACKET = 0x5d;
const BYTE_COLON = 0x3a;
const BYTE_COMMA = 0x2c;
/**
* Advances past any RFC 8259 JSON whitespace bytes.
*
* @param buffer - The trace byte buffer to inspect.
* @param start - The buffer index where scanning begins.
* @returns The index of the first non-whitespace byte, or the buffer length if the buffer ends.
*/
function skipWhitespace(
buffer: Uint8Array<ArrayBufferLike>,
start: number,
): number {
let i = start;
while (i < buffer.length) {
const byte = buffer[i];
if (byte !== 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) {
break;
}
i++;
}
return i;
}
/**
* Scans past a JSON string starting at the opening quotation mark.
*
* @param buffer - The trace byte buffer to scan.
* @param startQuotePos - The buffer index of the opening quotation mark.
* @returns The index immediately following the closing quotation mark.
* @throws {SyntaxError} If the string is unterminated before the end of the buffer.
*/
function skipString(
buffer: Uint8Array<ArrayBufferLike>,
startQuotePos: number,
): number {
let pos = startQuotePos + 1;
while (pos < buffer.length) {
const byte = buffer[pos];
if (byte === BYTE_BACKSLASH) {
if (pos + 1 >= buffer.length) {
throw new SyntaxError('Unterminated string in JSON');
}
pos += 2;
continue;
}
if (byte === BYTE_QUOTE) {
return pos + 1;
}
pos++;
}
throw new SyntaxError('Unterminated string in JSON');
}
/**
* Scans past a complete JSON value starting at the specified buffer position.
* Skips strings, compound objects, arrays, and primitive values without parsing them into memory.
*
* @param buffer - The trace byte buffer to scan.
* @param startPos - The buffer index where the value begins.
* @returns The index immediately following the skipped JSON value.
* @throws {SyntaxError} If the value contains unbalanced delimiters, unterminated strings, or empty primitive tokens.
*/
function skipValue(
buffer: Uint8Array<ArrayBufferLike>,
startPos: number,
): number {
let pos = skipWhitespace(buffer, startPos);
if (pos >= buffer.length) {
return pos;
}
const firstByte = buffer[pos];
if (firstByte === BYTE_QUOTE) {
return skipString(buffer, pos);
}
if (firstByte === BYTE_OPEN_BRACE || firstByte === BYTE_OPEN_BRACKET) {
const delimiterStack: number[] = [];
delimiterStack.push(
firstByte === BYTE_OPEN_BRACE ? BYTE_CLOSE_BRACE : BYTE_CLOSE_BRACKET,
);
pos++;
while (pos < buffer.length) {
const byte = buffer[pos];
if (byte === BYTE_QUOTE) {
pos = skipString(buffer, pos);
continue;
}
if (byte === BYTE_OPEN_BRACE) {
delimiterStack.push(BYTE_CLOSE_BRACE);
} else if (byte === BYTE_OPEN_BRACKET) {
delimiterStack.push(BYTE_CLOSE_BRACKET);
} else if (byte === BYTE_CLOSE_BRACE || byte === BYTE_CLOSE_BRACKET) {
const expected = delimiterStack.pop();
if (expected !== byte) {
throw new SyntaxError('Unexpected delimiter in JSON');
}
if (delimiterStack.length === 0) {
pos++;
return pos;
}
}
pos++;
}
throw new SyntaxError('Unexpected end of JSON input');
}
const valStart = pos;
while (pos < buffer.length) {
const byte = buffer[pos];
if (
byte === BYTE_COMMA ||
byte === BYTE_CLOSE_BRACE ||
byte === BYTE_CLOSE_BRACKET ||
byte === 0x20 ||
byte === 0x09 ||
byte === 0x0a ||
byte === 0x0d
) {
break;
}
pos++;
}
if (pos === valStart) {
throw new SyntaxError('Expected JSON value');
}
return pos;
}
/**
* Scans trace events from a JSON array in bounded batches to limit memory consumption.
*
* @param buffer - The raw byte buffer containing the events array.
* @param startPos - The index immediately after the opening array bracket.
* @param config - Configuration settings controlling batch sizes and text decoding.
* @returns An object containing the parsed events and the ending buffer position.
* @throws {SyntaxError} If the event array contains malformed JSON, unbalanced delimiters, or unexpected tokens.
*/
function parseEventsArray(
buffer: Uint8Array<ArrayBufferLike>,
startPos: number,
config: BatchConfig,
): {events: DevTools.TraceEngine.Types.Events.Event[]; endPos: number} {
const events: DevTools.TraceEngine.Types.Events.Event[] = [];
let batchStart = -1;
let lastEventEnd = -1;
let batchEventCount = 0;
let depth = 0;
let i = startPos;
let closed = false;
let state:
'expect_element_or_end' | 'expect_element' | 'expect_comma_or_end' =
'expect_element_or_end';
const flushBatch = (): void => {
if (batchStart !== -1 && lastEventEnd > batchStart) {
// Slicing between batchStart and lastEventEnd is safe for UTF-8 decoding.
// Both boundaries are ASCII structural braces ({ and }) that align with code unit boundaries.
const slice = buffer.subarray(batchStart, lastEventEnd);
const chunkText = config.decoder.decode(slice);
// Wrapping comma-delimited event objects in brackets creates a valid JSON array for the parser.
const parsed: DevTools.TraceEngine.Types.Events.Event[] = JSON.parse(
`[${chunkText}]`,
);
// Append items iteratively to avoid exceeding V8 call stack size limits on large batches.
for (const event of parsed) {
events.push(event);
}
batchStart = -1;
batchEventCount = 0;
}
};
while (i < buffer.length) {
const byte = buffer[i];
if (depth > 0) {
if (byte === BYTE_QUOTE) {
i = skipString(buffer, i);
continue;
}
if (byte === BYTE_OPEN_BRACE) {
depth++;
i++;
continue;
}
if (byte === BYTE_CLOSE_BRACE) {
depth--;
if (depth === 0) {
lastEventEnd = i + 1;
batchEventCount++;
state = 'expect_comma_or_end';
// Check thresholds after recording the completed event.
// Checking batchEventCount after incrementing ensures batches cap at exactly eventsPerBatch.
// Individual events are atomic JSON objects and cannot be split mid-event; when an event
// pushes currentBatchBytes over maxBatchBytes, the completed event is flushed immediately.
const currentBatchBytes = lastEventEnd - batchStart;
if (
batchEventCount >= config.eventsPerBatch ||
currentBatchBytes >= config.maxBatchBytes
) {
flushBatch();
}
}
i++;
continue;
}
i++;
continue;
}
// When depth is 0, scan top-level array delimiter and separator tokens.
if (byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d) {
i++;
continue;
}
if (byte === BYTE_OPEN_BRACE) {
if (state !== 'expect_element_or_end' && state !== 'expect_element') {
throw new SyntaxError('Expected "," or "]" in JSON array');
}
depth = 1;
if (batchStart === -1) {
batchStart = i;
}
i++;
continue;
}
if (byte === BYTE_COMMA) {
if (state !== 'expect_comma_or_end') {
throw new SyntaxError('Unexpected token "," in JSON array');
}
state = 'expect_element';
i++;
continue;
}
if (byte === BYTE_CLOSE_BRACKET) {
if (state === 'expect_element') {
throw new SyntaxError('Unexpected token "]" in JSON array');
}
closed = true;
i++;
break;
}
if (byte === BYTE_CLOSE_BRACE) {
throw new SyntaxError('Unexpected token "}" in JSON');
}
throw new SyntaxError('Unexpected token in JSON array');
}
if (!closed || depth !== 0) {
throw new SyntaxError('Unexpected end of JSON input');
}
flushBatch();
return {events, endPos: i};
}
/**
* Parses Chrome DevTools trace events and metadata from a raw byte buffer.
* Supports both root array format ([...]) and object container format ({"traceEvents": [...]}).
* Parses events in chunks to prevent V8 string length and memory exhaustion errors.
*
* @param buffer - The raw trace buffer to parse.
* @param options - Optional configuration settings for batch processing.
* @returns A {@link ParsedTraceBuffer} containing parsed trace events and optional file metadata, or an object with an empty events array if the buffer is empty or contains only whitespace.
* @throws {Error} If the buffer contains non-whitespace data that does not begin with a valid JSON array or object.
* @throws {SyntaxError} If the underlying JSON syntax within batches or metadata is invalid.
*/
export function parseTraceEventsFromBuffer(
buffer: Uint8Array<ArrayBufferLike>,
options: ParseTraceBufferOptions = {},
): ParsedTraceBuffer {
const eventsPerBatch =
typeof options.eventsPerBatch === 'number' &&
Number.isFinite(options.eventsPerBatch) &&
options.eventsPerBatch > 0
? options.eventsPerBatch
: DEFAULT_EVENTS_PER_BATCH;
const maxBatchBytes =
typeof options.maxBatchBytes === 'number' &&
Number.isFinite(options.maxBatchBytes) &&
options.maxBatchBytes > 0
? options.maxBatchBytes
: DEFAULT_MAX_BATCH_BYTES;
const decoder = new TextDecoder();
const config: BatchConfig = {
eventsPerBatch,
maxBatchBytes,
decoder,
};
let pos = skipWhitespace(buffer, 0);
if (pos >= buffer.length) {
return {events: []};
}
const firstByte = buffer[pos];
if (firstByte === BYTE_OPEN_BRACKET) {
const {events, endPos} = parseEventsArray(buffer, pos + 1, config);
const trailingPos = skipWhitespace(buffer, endPos);
if (trailingPos < buffer.length) {
throw new SyntaxError('Unexpected non-whitespace character after JSON');
}
return {events};
}
if (firstByte === BYTE_OPEN_BRACE) {
pos++;
let events: DevTools.TraceEngine.Types.Events.Event[] = [];
let metadata: DevTools.TraceEngine.Types.File.MetaData | undefined;
let closed = false;
let expectComma = false;
while (pos < buffer.length) {
pos = skipWhitespace(buffer, pos);
if (pos >= buffer.length) {
break;
}
if (buffer[pos] === BYTE_CLOSE_BRACE) {
closed = true;
pos++;
break;
}
if (expectComma) {
if (buffer[pos] !== BYTE_COMMA) {
throw new SyntaxError(
'Expected "," or "}" after property value in JSON',
);
}
pos++;
pos = skipWhitespace(buffer, pos);
}
if (pos >= buffer.length) {
break;
}
if (buffer[pos] !== BYTE_QUOTE) {
throw new SyntaxError('Expected property name or "}" in JSON');
}
const keyStart = pos;
pos = skipString(buffer, pos);
const keyJson = decoder.decode(buffer.subarray(keyStart, pos));
const parsedKey: unknown = JSON.parse(keyJson);
if (typeof parsedKey !== 'string') {
throw new SyntaxError('Expected property name in JSON');
}
const key = parsedKey;
pos = skipWhitespace(buffer, pos);
if (pos >= buffer.length || buffer[pos] !== BYTE_COLON) {
throw new SyntaxError('Expected ":" after property name in JSON');
}
pos++;
pos = skipWhitespace(buffer, pos);
if (key === 'traceEvents') {
if (pos >= buffer.length || buffer[pos] !== BYTE_OPEN_BRACKET) {
throw new SyntaxError('Expected "[" for "traceEvents" in JSON');
}
const arrayResult = parseEventsArray(buffer, pos + 1, config);
events = arrayResult.events;
pos = arrayResult.endPos;
} else if (key === 'metadata') {
if (pos >= buffer.length || buffer[pos] !== BYTE_OPEN_BRACE) {
throw new SyntaxError('Expected "{" for "metadata" in JSON');
}
const metaStart = pos;
pos = skipValue(buffer, pos);
const metaSlice = buffer.subarray(metaStart, pos);
const metaText = decoder.decode(metaSlice);
const parsedMetadata: DevTools.TraceEngine.Types.File.MetaData =
JSON.parse(metaText);
metadata = parsedMetadata;
} else {
pos = skipValue(buffer, pos);
}
expectComma = true;
}
if (!closed) {
throw new SyntaxError('Unexpected end of JSON input');
}
const trailingPos = skipWhitespace(buffer, pos);
if (trailingPos < buffer.length) {
throw new SyntaxError('Unexpected non-whitespace character after JSON');
}
return {events, metadata};
}
throw new Error('Invalid trace buffer: expected JSON object or array.');
}
+61 -19
View File
@@ -6,19 +6,35 @@
import {DevTools} from '../third_party/index.js';
import {logger} from '../utils/logger.js';
import {parseTraceEventsFromBuffer} from './ChunkedTraceParser.js';
/**
* Represents the successful output of processing a performance trace.
*/
export interface TraceResult {
/** The fully processed trace model output containing event graphs and handler data. */
parsedTrace: DevTools.TraceEngine.TraceModel.ParsedTrace;
/** Computed performance insights for navigations in the trace, or null if unavailable. */
insights: DevTools.TraceEngine.Insights.Types.TraceInsightSets | null;
}
/**
* Type guard that verifies if an operation returned a valid TraceResult.
*
* @param x - The result or error object to inspect.
* @returns True if the object is a TraceResult; otherwise false.
*/
export function traceResultIsSuccess(
x: TraceResult | TraceParseError,
): x is TraceResult {
return 'parsedTrace' in x;
}
/**
* Represents an error encountered while reading or parsing a trace buffer.
*/
export interface TraceParseError {
/** The descriptive error message detailing why trace processing failed. */
error: string;
}
@@ -26,12 +42,13 @@ export interface TraceParseError {
* Parses raw JSON trace buffer bytes into a DevTools TraceEngine representation.
*
* Accepts either a JSON array of trace events or an object with a `traceEvents` field.
* A new trace engine model is created per call to ensure session isolation and prevent
* memory retention.
* Events are parsed in chunks directly from the raw byte buffer to avoid V8 string
* allocation limits. A new trace engine model is created per call to ensure session
* isolation and prevent memory retention.
*
* @param buffer Raw UTF-8 encoded JSON bytes representing trace data.
* @param metadata Optional throttling configurations applied during recording.
* @returns A {@link TraceResult} with parsed traces and insights, or a {@link TraceParseError} on failure.
* @param buffer - Raw binary trace data representing trace events and metadata.
* @param metadata - Optional throttling configurations applied during recording; overrides embedded file metadata when defined.
* @returns A promise resolving to a {@link TraceResult} with parsed traces and insights, or a {@link TraceParseError} on failure.
*/
export async function parseRawTraceBuffer(
buffer: Uint8Array<ArrayBufferLike> | undefined,
@@ -40,31 +57,37 @@ export async function parseRawTraceBuffer(
networkThrottling?: string;
},
): Promise<TraceResult | TraceParseError> {
if (!buffer) {
if (!buffer || buffer.length === 0) {
return {
error: 'No buffer was provided.',
};
}
const asString = new TextDecoder().decode(buffer);
if (!asString) {
return {
error: 'Decoding the trace buffer returned an empty string.',
};
}
try {
const data = JSON.parse(asString) as
| {
traceEvents: DevTools.TraceEngine.Types.Events.Event[];
}
| DevTools.TraceEngine.Types.Events.Event[];
const {events, metadata: fileMetadata} = parseTraceEventsFromBuffer(buffer);
if (events.length === 0) {
return {
error: 'No trace events were found in the trace buffer.',
};
}
const combinedMetadata: DevTools.TraceEngine.Types.File.MetaData = {
...fileMetadata,
...(metadata?.cpuThrottling !== undefined
? {cpuThrottling: metadata.cpuThrottling}
: {}),
...(metadata?.networkThrottling !== undefined
? {networkThrottling: metadata.networkThrottling}
: {}),
};
const hasMetadata = Object.keys(combinedMetadata).length > 0;
const events = Array.isArray(data) ? data : data.traceEvents;
// Instantiate a fresh TraceModel per invocation because Model permanently
// retains parsed traces in its internal `#traces` array, which causes an
// unbounded memory leak if reused across sessions.
const engine =
DevTools.TraceEngine.TraceModel.Model.createWithAllHandlers();
await engine.parse(events, {metadata});
await engine.parse(events, {
metadata: hasMetadata ? combinedMetadata : undefined,
});
const parsedTrace = engine.parsedTrace();
if (!parsedTrace) {
return {
@@ -93,6 +116,13 @@ ${DevTools.PerformanceTraceFormatter.callFrameDataFormatDescription}
${DevTools.PerformanceTraceFormatter.networkDataFormatDescription}`;
/**
* Generates a Markdown summary of main thread activity and network metrics from a parsed trace.
*
* @param result - The parsed trace result to summarize.
* @param deviceScope - Optional CrUX device scope to filter field data.
* @returns Formatted Markdown text describing performance findings.
*/
export function getTraceSummary(
result: TraceResult,
deviceScope?: DevTools.CrUXManager.DeviceScope | null,
@@ -107,10 +137,22 @@ ${summaryText}
${extraFormatDescriptions}`;
}
/** Identifies a specific performance insight model type supported by the trace engine. */
export type InsightName =
keyof DevTools.TraceEngine.Insights.Types.InsightModels;
/** Represents the result of an insight formatting request. */
export type InsightOutput = {output: string} | {error: string};
/**
* Formats a specific performance insight from a parsed trace for display.
*
* @param result - The parsed trace result containing computed insight sets.
* @param insightSetId - The identifier of the target insight set.
* @param insightName - The name of the insight model to extract.
* @param deviceScope - Optional CrUX device scope to contextualize metrics.
* @returns An object containing the formatted insight output text or an error message.
*/
export function getInsightOutput(
result: TraceResult,
insightSetId: string,
+4 -4
View File
@@ -786,8 +786,8 @@ exports[`McpResponse network pagination > trace summaries > includes the trace s
## Summary of Performance trace findings:
URL: https://web.dev/
Trace bounds: {min: 122410994891µs, max: 122416385853µs}
CPU throttling: none
Network throttling: none
CPU throttling: 1x
Network throttling: No throttling
# Available insight sets
@@ -903,8 +903,8 @@ exports[`McpResponse network pagination > trace summaries > includes the trace s
## Summary of Performance trace findings:
URL: https://web.dev/
Trace bounds: {min: 122410994891µs, max: 122416385853µs}
CPU throttling: none
Network throttling: none
CPU throttling: 1x
Network throttling: No throttling
# Available insight sets
@@ -0,0 +1,407 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {describe, it} from 'node:test';
import {parseTraceEventsFromBuffer} from '../../src/processors/ChunkedTraceParser.js';
import {loadTraceAsBuffer} from './fixtures/load.js';
describe('ChunkedTraceParser', () => {
const encoder = new TextEncoder();
it('parses an empty trace array', () => {
const buffer = encoder.encode('[]');
const result = parseTraceEventsFromBuffer(buffer);
assert.strictEqual(result.events.length, 0);
});
it('parses an empty traceEvents object', () => {
const buffer = encoder.encode('{"traceEvents": []}');
const result = parseTraceEventsFromBuffer(buffer);
assert.strictEqual(result.events.length, 0);
});
it('parses a bare array of events', () => {
const json = JSON.stringify([
{name: 'event-1', ph: 'X', ts: 100},
{name: 'event-2', ph: 'B', ts: 200},
]);
const buffer = encoder.encode(json);
const result = parseTraceEventsFromBuffer(buffer);
assert.strictEqual(result.events.length, 2);
assert.strictEqual(result.events[0]?.name, 'event-1');
assert.strictEqual(result.events[1]?.name, 'event-2');
});
it('parses an object with traceEvents and metadata', () => {
const json = JSON.stringify({
traceEvents: [
{name: 'event-1', ph: 'X', ts: 100},
{name: 'event-2', ph: 'E', ts: 150},
],
metadata: {
cpuThrottling: 4,
},
});
const buffer = encoder.encode(json);
const result = parseTraceEventsFromBuffer(buffer);
assert.strictEqual(result.events.length, 2);
assert.strictEqual(result.events[0]?.name, 'event-1');
assert.strictEqual(result.events[1]?.name, 'event-2');
assert.strictEqual(result.metadata?.cpuThrottling, 4);
});
it('parses metadata when metadata appears before traceEvents', () => {
const json = JSON.stringify({
metadata: {
cpuThrottling: 2,
},
traceEvents: [{name: 'event-1', ph: 'X', ts: 50}],
});
const buffer = encoder.encode(json);
const result = parseTraceEventsFromBuffer(buffer);
assert.strictEqual(result.events.length, 1);
assert.strictEqual(result.events[0]?.name, 'event-1');
assert.strictEqual(result.metadata?.cpuThrottling, 2);
});
it('skips unrelated top-level properties', () => {
const json = JSON.stringify({
displayTimeUnit: 'ms',
traceEvents: [{name: 'event-1', ph: 'X', ts: 10}],
otherData: {nested: true, count: 42},
});
const buffer = encoder.encode(json);
const result = parseTraceEventsFromBuffer(buffer);
assert.strictEqual(result.events.length, 1);
assert.strictEqual(result.events[0]?.name, 'event-1');
});
it('handles strings containing braces, brackets, and escaped quotes', () => {
const json = JSON.stringify({
traceEvents: [
{
name: 'event-{with-braces}',
args: {
data: {
url: 'https://example.com/hello?q=} { [bracket] "escaped" \\\\ backslash',
},
},
},
],
metadata: {
title: 'nested } braces { inside',
},
});
const buffer = encoder.encode(json);
const result = parseTraceEventsFromBuffer(buffer);
assert.strictEqual(result.events.length, 1);
assert.strictEqual(result.events[0]?.name, 'event-{with-braces}');
assert.strictEqual(
result.events[0]?.args?.data?.url,
'https://example.com/hello?q=} { [bracket] "escaped" \\\\ backslash',
);
assert.strictEqual(result.metadata?.title, 'nested } braces { inside');
});
it('handles nested objects in event args data', () => {
const json = JSON.stringify({
traceEvents: [
{
name: 'deep',
args: {
data: {
url: 'https://example.com',
},
},
},
],
});
const buffer = encoder.encode(json);
const result = parseTraceEventsFromBuffer(buffer);
assert.strictEqual(result.events.length, 1);
assert.strictEqual(result.events[0]?.name, 'deep');
assert.strictEqual(
result.events[0]?.args?.data?.url,
'https://example.com',
);
});
it('batches events correctly according to eventsPerBatch', () => {
const json = JSON.stringify({
traceEvents: [
{name: 'e1', ts: 1},
{name: 'e2', ts: 2},
{name: 'e3', ts: 3},
{name: 'e4', ts: 4},
{name: 'e5', ts: 5},
],
});
const buffer = encoder.encode(json);
// Batch size of 2 on 5 items tests full batches and remainder batch.
const result = parseTraceEventsFromBuffer(buffer, {eventsPerBatch: 2});
assert.strictEqual(result.events.length, 5);
assert.strictEqual(result.events[0]?.name, 'e1');
assert.strictEqual(result.events[1]?.name, 'e2');
assert.strictEqual(result.events[2]?.name, 'e3');
assert.strictEqual(result.events[3]?.name, 'e4');
assert.strictEqual(result.events[4]?.name, 'e5');
});
it('parses a real trace fixture matching standard JSON.parse', () => {
const rawData = loadTraceAsBuffer('basic-trace.json.gz');
const result = parseTraceEventsFromBuffer(rawData, {eventsPerBatch: 10});
const standardJsonText = new TextDecoder().decode(rawData);
const standardParsed:
| Array<{name: string; ts: number}>
| {traceEvents: Array<{name: string; ts: number}>} =
JSON.parse(standardJsonText);
const expectedEvents = Array.isArray(standardParsed)
? standardParsed
: standardParsed.traceEvents;
assert.strictEqual(result.events.length, expectedEvents.length);
assert.strictEqual(result.events[0]?.name, expectedEvents[0]?.name);
assert.strictEqual(result.events[0]?.ts, expectedEvents[0]?.ts);
const lastIndex = result.events.length - 1;
assert.strictEqual(
result.events[lastIndex]?.name,
expectedEvents[lastIndex]?.name,
);
});
it('parses a larger trace fixture in multiple batches', () => {
const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz');
const result = parseTraceEventsFromBuffer(rawData, {eventsPerBatch: 5000});
const standardJsonText = new TextDecoder().decode(rawData);
const standardParsed:
| Array<{name: string; ts: number}>
| {traceEvents: Array<{name: string; ts: number}>} =
JSON.parse(standardJsonText);
const expectedEvents = Array.isArray(standardParsed)
? standardParsed
: standardParsed.traceEvents;
assert.strictEqual(result.events.length, expectedEvents.length);
assert.strictEqual(result.events.length, 47705);
assert.strictEqual(result.events[0]?.name, expectedEvents[0]?.name);
const lastIndex = result.events.length - 1;
assert.strictEqual(
result.events[lastIndex]?.name,
expectedEvents[lastIndex]?.name,
);
});
it('throws SyntaxError when a bare array is truncated before closing bracket', () => {
const buffer = encoder.encode('[{"name": "e1", "ts": 1}');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('throws SyntaxError when a string inside an event is unterminated', () => {
const buffer = encoder.encode('[{"name": "unterminated');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('throws SyntaxError when an object container is truncated before closing brace', () => {
const buffer = encoder.encode('{"traceEvents": [{"name": "e1"}]');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('throws SyntaxError on stray closing braces', () => {
const buffer = encoder.encode('[{"name": "e1"}}]');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('handles multibyte UTF-8 characters across batch boundaries', () => {
const json = JSON.stringify({
traceEvents: [
{
name: '日本語テスト-1',
args: {data: {url: 'https://example.com/こんにちは世界-🚀'}},
},
{
name: 'emoji-🎉-2',
args: {data: {url: 'https://example.com/€100-and-50¢'}},
},
{
name: 'umlaut-äöü-3',
args: {data: {url: 'https://example.com/Deutsch-äöü'}},
},
],
});
const buffer = encoder.encode(json);
const result = parseTraceEventsFromBuffer(buffer, {eventsPerBatch: 1});
assert.strictEqual(result.events.length, 3);
assert.strictEqual(result.events[0]?.name, '日本語テスト-1');
assert.strictEqual(
result.events[0]?.args?.data?.url,
'https://example.com/こんにちは世界-🚀',
);
assert.strictEqual(result.events[1]?.name, 'emoji-🎉-2');
assert.strictEqual(
result.events[1]?.args?.data?.url,
'https://example.com/€100-and-50¢',
);
assert.strictEqual(result.events[2]?.name, 'umlaut-äöü-3');
assert.strictEqual(
result.events[2]?.args?.data?.url,
'https://example.com/Deutsch-äöü',
);
});
it('parses large batches exceeding function argument limits without call stack errors', () => {
const eventCount = 70_000;
const items: string[] = [];
for (let idx = 0; idx < eventCount; idx++) {
items.push(`{"name":"evt-${idx}","ts":${idx}}`);
}
const buffer = encoder.encode(`[${items.join(',')}]`);
const result = parseTraceEventsFromBuffer(buffer, {
eventsPerBatch: eventCount,
});
assert.strictEqual(result.events.length, eventCount);
assert.strictEqual(result.events[0]?.name, 'evt-0');
assert.strictEqual(result.events[69_999]?.name, 'evt-69999');
});
it('throws SyntaxError when non-whitespace characters follow a closed array', () => {
const buffer = encoder.encode('[{"name": "e1"}] extra');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('throws SyntaxError when non-whitespace characters follow a closed object', () => {
const buffer = encoder.encode('{"traceEvents": []} extra');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('throws SyntaxError when a property key is missing a colon in object container', () => {
const buffer = encoder.encode('{"traceEvents" []}');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('throws SyntaxError when a comma is missing between object properties', () => {
const buffer = encoder.encode('{"traceEvents": [] "metadata": {}}');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('throws SyntaxError when an object key string is unterminated', () => {
const buffer = encoder.encode('{"traceEvents');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('batches events by maxBatchBytes when byte threshold is exceeded', () => {
const e1 = {
name: 'event-one-long-name',
args: {detail: 'some-payload-string-1'},
};
const e2 = {
name: 'event-two-long-name',
args: {detail: 'some-payload-string-2'},
};
const json = JSON.stringify([e1, e2]);
const buffer = encoder.encode(json);
const result = parseTraceEventsFromBuffer(buffer, {
eventsPerBatch: 100,
maxBatchBytes: 40,
});
assert.strictEqual(result.events.length, 2);
assert.strictEqual(result.events[0]?.name, 'event-one-long-name');
assert.strictEqual(result.events[1]?.name, 'event-two-long-name');
});
it('falls back to defaults when invalid batch options are provided', () => {
const json = JSON.stringify([{name: 'e1'}, {name: 'e2'}]);
const buffer = encoder.encode(json);
const result = parseTraceEventsFromBuffer(buffer, {
eventsPerBatch: -5,
maxBatchBytes: NaN,
});
assert.strictEqual(result.events.length, 2);
assert.strictEqual(result.events[0]?.name, 'e1');
assert.strictEqual(result.events[1]?.name, 'e2');
});
it('throws SyntaxError when a comma is missing between events', () => {
const buffer = encoder.encode('[{"name": "e1"} {"name": "e2"}]');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('throws SyntaxError on trailing comma in events array', () => {
const buffer = encoder.encode('[{"name": "e1"}, ]');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('throws SyntaxError on non-object items in events array', () => {
const buffer1 = encoder.encode('[null, {"name": "e1"}]');
assert.throws(() => parseTraceEventsFromBuffer(buffer1), SyntaxError);
const buffer2 = encoder.encode('[123, {"name": "e1"}]');
assert.throws(() => parseTraceEventsFromBuffer(buffer2), SyntaxError);
const buffer3 = encoder.encode('["invalid", {"name": "e1"}]');
assert.throws(() => parseTraceEventsFromBuffer(buffer3), SyntaxError);
});
it('throws SyntaxError on nested array elements in events array', () => {
const buffer = encoder.encode('[ [], {"name": "e1"} ]');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('throws SyntaxError on mismatched delimiters in skipped values', () => {
const buffer = encoder.encode('{"other": {"a": ]}, "traceEvents": []}');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('throws SyntaxError on empty primitive value in container object', () => {
const buffer = encoder.encode('{"other": , "traceEvents": []}');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('throws SyntaxError on unterminated escape in skipped string', () => {
const buffer = encoder.encode('{"other": "\\');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('correctly decodes escaped container object keys', () => {
const json =
'{"\\u0074raceEvents": [{"name": "e1"}], "\\u006d\\u0065\\u0074\\u0061\\u0064\\u0061\\u0074\\u0061": {"cpuThrottling": 2}}';
const buffer = encoder.encode(json);
const result = parseTraceEventsFromBuffer(buffer);
assert.strictEqual(result.events.length, 1);
assert.strictEqual(result.events[0]?.name, 'e1');
assert.strictEqual(result.metadata?.cpuThrottling, 2);
});
it('throws SyntaxError when traceEvents property value is not an array', () => {
const buffer = encoder.encode('{"traceEvents": "not-an-array"}');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('throws SyntaxError when metadata property value is not an object', () => {
const buffer = encoder.encode(
'{"metadata": "not-an-object", "traceEvents": []}',
);
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
it('throws SyntaxError on trailing comma in container object', () => {
const buffer = encoder.encode('{"traceEvents": [],}');
assert.throws(() => parseTraceEventsFromBuffer(buffer), SyntaxError);
});
});
@@ -2,8 +2,8 @@ exports[`Trace parsing > can format results of a trace 1`] = `
## Summary of Performance trace findings:
URL: https://web.dev/
Trace bounds: {min: 122410994891µs, max: 122416385853µs}
CPU throttling: none
Network throttling: none
CPU throttling: 1x
Network throttling: No throttling
# Available insight sets
+32
View File
@@ -78,4 +78,36 @@ describe('Trace parsing', () => {
error: 'No buffer was provided.',
});
});
it('does not clobber file metadata with undefined caller properties', async () => {
const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz');
// The caller passes explicit undefined for networkThrottling (matching production behavior when conditions are null).
const result = await parseRawTraceBuffer(rawData, {
cpuThrottling: undefined,
networkThrottling: undefined,
});
if ('error' in result) {
assert.fail(`Unexpected parse failure: ${result.error}`);
}
const summary = getTraceSummary(result);
// Verify that throttling from file metadata is preserved rather than reverting to 'none'.
assert.ok(summary.includes('CPU throttling: 1x'));
assert.ok(summary.includes('Network throttling: No throttling'));
});
it('overrides file metadata when caller specifies defined throttling options', async () => {
const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz');
// The caller passes explicit defined overrides.
const result = await parseRawTraceBuffer(rawData, {
cpuThrottling: 4,
networkThrottling: 'Slow 3G',
});
if ('error' in result) {
assert.fail(`Unexpected parse failure: ${result.error}`);
}
const summary = getTraceSummary(result);
// Verify that caller-specified throttling overrides file metadata.
assert.ok(summary.includes('CPU throttling: 4x'));
assert.ok(summary.includes('Network throttling: Slow 3G'));
});
});