Skip to content

Commit bcf0a07

Browse files
committed
Bug 2024449 - Add importer for streamed profile format (JSON Lines)
Add support for importing the "streamed profile" format, a JSON Lines format where each line is a JSON object: a meta line, a thread declaration line, then marker lines. This format is produced by tools like resourcemonitor.py for streaming resource-usage profiles. The importer passes through meta and thread objects as-is and respects the input's preprocessedProfileVersion, so the standard profile upgraders handle all format migrations. stringArray placement is version-aware: per-thread for versions < 56, in profile.shared for versions >= 56. Also adds early detection in _extractJsonFromArrayBuffer so that JSON Lines content reaches the string-based format detection instead of failing JSON.parse.
1 parent 3d5a1c1 commit bcf0a07

File tree

4 files changed

+499
-2
lines changed

4 files changed

+499
-2
lines changed

src/actions/receive-profile.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,7 +1174,9 @@ async function _extractZipFromResponse(
11741174
}
11751175

11761176
/**
1177-
* Parse JSON from an optionally gzipped array buffer.
1177+
* Decode an optionally gzipped array buffer into a profile-shaped value.
1178+
* Returns parsed JSON for normal profiles, or the raw text string for
1179+
* streamed profiles (JSON Lines) which are not valid single-object JSON.
11781180
*/
11791181
async function _extractJsonFromArrayBuffer(
11801182
arrayBuffer: ArrayBuffer
@@ -1186,7 +1188,16 @@ async function _extractJsonFromArrayBuffer(
11861188
}
11871189

11881190
const textDecoder = new TextDecoder();
1189-
return JSON.parse(textDecoder.decode(profileBytes));
1191+
const text = textDecoder.decode(profileBytes);
1192+
1193+
// Streamed profiles (JSON Lines) start with {"type":"meta" and are not
1194+
// valid single-object JSON. Return the text directly so that the string
1195+
// format detection in unserializeProfileOfArbitraryFormat can handle it.
1196+
if (text.startsWith('{"type":"meta"')) {
1197+
return text;
1198+
}
1199+
1200+
return JSON.parse(text);
11901201
}
11911202

11921203
/**
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
/**
6+
* Importer for the "streamed profile" format (JSON Lines / .jsonl).
7+
*
8+
* This format is produced by tools like resourcemonitor.py and streams one
9+
* JSON object per line:
10+
*
11+
* Line 1: {"type":"meta", ...} — profile metadata (markerSchema, categories, etc.)
12+
* Line 2: {"type":"thread", ...} — thread declaration and structure
13+
* Line 3+: {"type":"marker", ...} — one marker per line
14+
*
15+
* Threads must be declared before any markers that belong to them. The
16+
* importer passes through meta and thread objects from the input, only
17+
* adding the parsed marker columnar arrays and the stringArray populated
18+
* from marker names. The producing tool is responsible for emitting a
19+
* structure that matches its declared preprocessedProfileVersion, including
20+
* any required tables (stackTable, frameTable, etc.). The standard profile
21+
* upgraders then migrate the result to the current version.
22+
*
23+
* ## Future extensibility (comments only — nothing is implemented yet):
24+
*
25+
* - In the future there may be one JSON Lines file streamed *per process*,
26+
* not just one global file. The importer would then need to merge multiple
27+
* files or accept a list of streams.
28+
*
29+
* - "type": "counter" and "type": "sample" lines are expected to be added
30+
* when streaming profiles that contain more than just markers (e.g. CPU
31+
* sampling data, performance counters).
32+
*
33+
* - A `tid` (thread ID) attribute is expected to be included in each line
34+
* in the future to route markers to different threads. When `tid` is
35+
* absent, the marker belongs to the first declared thread.
36+
*
37+
* - The current resource-usage profiles are a simple case: single process,
38+
* single thread. But the format is designed to support multi-process,
39+
* multi-thread profiles in the future.
40+
*/
41+
42+
import type { MarkerPhase } from 'firefox-profiler/types/gecko-profile';
43+
import { INSTANT, INTERVAL } from 'firefox-profiler/app-logic/constants';
44+
import { StringTable } from 'firefox-profiler/utils/string-table';
45+
46+
/**
47+
* Detect whether the input string is a streamed profile in JSON Lines format.
48+
* The first line always starts with {"type":"meta" (the "type" key is
49+
* guaranteed to be the first key), so we can detect the format by checking
50+
* for this prefix without parsing the entire line.
51+
*/
52+
export function isStreamedProfileFormat(profile: string): boolean {
53+
return profile.startsWith('{"type":"meta"');
54+
}
55+
56+
/**
57+
* Convert a streamed profile (JSON Lines) string into a profile object
58+
* that the standard profile upgraders can process. The meta and thread
59+
* objects are passed through from the input; the importer only builds
60+
* the marker columnar arrays and the stringArray.
61+
*/
62+
export function convertStreamedProfile(profileText: string): any {
63+
const lines = profileText.split('\n').filter((line) => line.trim() !== '');
64+
65+
if (lines.length === 0) {
66+
throw new Error('Streamed profile is empty.');
67+
}
68+
69+
// --- Parse meta line ---
70+
const metaObj = JSON.parse(lines[0]);
71+
if (metaObj.type !== 'meta') {
72+
throw new Error('First line of streamed profile must be a "meta" object.');
73+
}
74+
75+
const { type: _metaType, ...meta } = metaObj;
76+
77+
// --- Parse remaining lines ---
78+
// Threads must be declared (via type=thread lines) before markers can
79+
// reference them. Currently there is only one thread per file; in the
80+
// future, markers will use a `tid` field to target a specific thread.
81+
const version = meta.preprocessedProfileVersion ?? 0;
82+
83+
// Marker names in the streamed format are human-readable strings. The
84+
// importer interns them into a stringArray with numeric indices, as
85+
// expected by the processed profile format. Before version 56 the
86+
// stringArray lives on each thread; from version 56 onward it is shared
87+
// across all threads in profile.shared.stringArray.
88+
const useSharedStringArray = version >= 56;
89+
const stringArray: string[] = [];
90+
const stringTable = StringTable.withBackingArray(stringArray);
91+
92+
let thread: Record<string, any> | null = null;
93+
94+
for (let i = 1; i < lines.length; i++) {
95+
const lineObj = JSON.parse(lines[i]);
96+
97+
switch (lineObj.type) {
98+
case 'thread': {
99+
const { type: _type, ...threadObj } = lineObj;
100+
if (!useSharedStringArray) {
101+
threadObj.stringArray = stringArray;
102+
}
103+
threadObj.markers = {
104+
name: [] as number[],
105+
startTime: [] as Array<number | null>,
106+
endTime: [] as Array<number | null>,
107+
phase: [] as MarkerPhase[],
108+
category: [] as number[],
109+
data: [] as Array<any>,
110+
length: 0,
111+
};
112+
thread = threadObj;
113+
break;
114+
}
115+
case 'marker': {
116+
if (thread === null) {
117+
throw new Error(
118+
'Streamed profile contains a marker before any thread declaration.'
119+
);
120+
}
121+
// Future: use lineObj.tid to look up the target thread.
122+
const { markers } = thread;
123+
markers.name.push(stringTable.indexForString(lineObj.name));
124+
markers.startTime.push(lineObj.startTime ?? null);
125+
const endTime: number | null = lineObj.endTime ?? null;
126+
markers.endTime.push(endTime);
127+
markers.phase.push(endTime === null ? INSTANT : INTERVAL);
128+
markers.category.push(lineObj.category ?? 0);
129+
markers.data.push(lineObj.data ?? null);
130+
markers.length++;
131+
break;
132+
}
133+
default:
134+
// Future: handle "counter", "sample", and other line types here.
135+
break;
136+
}
137+
}
138+
139+
if (thread === null) {
140+
throw new Error('Streamed profile contains no thread declaration.');
141+
}
142+
143+
const profile: any = {
144+
meta,
145+
libs: [],
146+
threads: [thread],
147+
};
148+
149+
if (useSharedStringArray) {
150+
profile.shared = { stringArray };
151+
}
152+
153+
return profile;
154+
}

src/profile-logic/process-profile.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import {
2525
convertFlameGraphProfile,
2626
} from './import/flame-graph';
2727
import { isArtTraceFormat, convertArtTraceProfile } from './import/art-trace';
28+
import {
29+
isStreamedProfileFormat,
30+
convertStreamedProfile,
31+
} from './import/streamed-profile';
2832
import {
2933
PROCESSED_PROFILE_VERSION,
3034
INTERVAL,
@@ -1993,6 +1997,8 @@ export async function unserializeProfileOfArbitraryFormat(
19931997
arbitraryFormat = convertPerfScriptProfile(arbitraryFormat);
19941998
} else if (isFlameGraphFormat(arbitraryFormat)) {
19951999
arbitraryFormat = convertFlameGraphProfile(arbitraryFormat);
2000+
} else if (isStreamedProfileFormat(arbitraryFormat)) {
2001+
arbitraryFormat = convertStreamedProfile(arbitraryFormat);
19962002
} else {
19972003
// Try parsing as JSON.
19982004
arbitraryFormat = JSON.parse(arbitraryFormat);

0 commit comments

Comments
 (0)