Skip to content

Commit 9c784c7

Browse files
committed
support binary format0 profiles
1 parent 2a0f56d commit 9c784c7

11 files changed

Lines changed: 397 additions & 73 deletions

package-lock.json

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"vite-plugin-singlefile": "^2.3.0"
4141
},
4242
"dependencies": {
43-
"d3-hierarchy": "^3.1.2"
43+
"d3-hierarchy": "^3.1.2",
44+
"pprof-format": "^2.2.1"
4445
}
4546
}

src/app/ProfileCollection.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
* - **Count**: Number of visible goroutines under each container
2727
*/
2828

29-
import { File as ParserFile, Frame as ParserFrame } from '../parser/types.js';
29+
import { ParsedFile, Frame as ParserFrame } from '../parser/types.js';
3030
import {
3131
UniqueStack,
3232
Frame,
@@ -304,7 +304,7 @@ export interface ProfileCollectionSettings {
304304
export class ProfileCollection {
305305
private categories: Category[] = [];
306306
private stackForTraceId: Map<string, { category: Category; stack: UniqueStack }> = new Map();
307-
private parsedFiles = new Map<string, ParserFile>();
307+
private parsedFiles = new Map<string, ParsedFile>();
308308
private settings: ProfileCollectionSettings;
309309
private stackNamer: StackNamer;
310310
private currentFilter: string = '';
@@ -452,7 +452,7 @@ export class ProfileCollection {
452452
/**
453453
* Import parser results into app File structure
454454
*/
455-
private importParsedFile(parserFile: ParserFile, fileName: string, nameInIds: boolean) {
455+
private importParsedFile(parserFile: ParsedFile, fileName: string, nameInIds: boolean) {
456456
const fileId = `f${this.nextFileId++}`;
457457

458458
// Process each stack group
@@ -746,7 +746,7 @@ export class ProfileCollection {
746746
/**
747747
* Add a file to the collection with optional custom name
748748
*/
749-
addFile(parsedFile: ParserFile, customName?: string): void {
749+
addFile(parsedFile: ParsedFile, customName?: string): void {
750750
if (this.parsedFiles.size == 1) {
751751
// If there is only one file, we need to put its name into its ids before
752752
// we add another.
@@ -863,7 +863,7 @@ export class ProfileCollection {
863863
this.stackNamer.setRules(newSettings.titleManipulationRules);
864864

865865
// Store current files with their names
866-
const files: Array<{ name: string; data: ParserFile }> = [];
866+
const files: Array<{ name: string; data: ParsedFile }> = [];
867867
for (const [name, data] of this.parsedFiles) {
868868
files.push({ name, data });
869869
}

src/app/SettingsManager.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export class SettingsManager {
103103
private changeCallback: ((settings: AppSettings) => void) | null = null;
104104
private defaultSettings: AppSettings;
105105

106-
constructor(customizer?: (settings: AppSettings) => AppSettings) {
106+
constructor(customizer?: (settings: AppSettings) => AppSettings, skipLoad?: boolean) {
107107
const builtinDefaults = this.getBuiltinDefaults();
108108
const customizedDefaults = customizer ? customizer(builtinDefaults) : builtinDefaults;
109109

@@ -112,7 +112,9 @@ export class SettingsManager {
112112

113113
this.defaultSettings = customizedDefaults;
114114
this.settings = { ...this.defaultSettings };
115-
this.loadSettings();
115+
if (!skipLoad) {
116+
this.loadSettings();
117+
}
116118
}
117119

118120
/**

src/parser/parser.ts

Lines changed: 193 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
* Core parser for Go stack trace files
33
*/
44

5-
import { File, Result, Group, Frame, Goroutine } from './types.js';
5+
import { ParsedFile, Result, Group, Frame, Goroutine } from './types.js';
6+
import { Profile } from 'pprof-format';
67

78
/**
89
* Simple FNV-1a hash implementation as fallback when crypto.subtle is unavailable
@@ -64,13 +65,46 @@ export class FileParser {
6465
}
6566

6667
/**
67-
* Parse a file and return common data structure
68+
* Parse a Blob or File (handles binary detection and decompression)
6869
*/
69-
async parseFile(content: string, fileName: string): Promise<Result> {
70-
if (this.detectFormat2(content)) {
71-
return await this.parseFormat2(content, fileName);
70+
async parseFile(blob: Blob, fileName?: string): Promise<Result> {
71+
// Read first 2 bytes to detect gzip magic bytes
72+
const chunk = await blob.slice(0, 2).arrayBuffer();
73+
const bytes = new Uint8Array(chunk);
74+
const isGzipped = bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
75+
76+
// Use provided fileName or default
77+
const name = fileName || 'unknown';
78+
79+
if (isGzipped) {
80+
// Binary format0 - stream decompression
81+
return await this.parseFormat0(blob, name);
7282
} else {
83+
// Text format - read as text and dispatch
84+
const content = await blob.text();
85+
return this.parseString(content, name);
86+
}
87+
}
88+
89+
/**
90+
* Parse string content (detects format1/2 and dispatches)
91+
*/
92+
async parseString(content: string, fileName: string): Promise<Result> {
93+
// Handle empty content
94+
if (!content.trim()) {
95+
return { success: true, data: { originalName: fileName, groups: [] } };
96+
}
97+
98+
if (this.detectFormat1(content)) {
7399
return await this.parseFormat1(content, fileName);
100+
} else {
101+
// Assume format 2, but validate it matches the expected pattern
102+
if (this.detectFormat2(content)) {
103+
return await this.parseFormat2(content, fileName);
104+
} else {
105+
// Return empty result for unrecognized content instead of error
106+
return { success: true, data: { originalName: fileName, groups: [] } };
107+
}
74108
}
75109
}
76110

@@ -106,13 +140,120 @@ export class FileParser {
106140
return null;
107141
}
108142

143+
144+
private detectFormat1(content: string): boolean {
145+
// Format 1 starts with "goroutine profile:" header - check first 18 characters
146+
return content.startsWith('goroutine profile:');
147+
}
148+
109149
private detectFormat2(content: string): boolean {
110150
// Format 2 has individual goroutine entries with "goroutine N ["
111151
// Check if content starts with goroutine line OR contains goroutine lines (for test logs)
112152
const trimmed = content.trim();
113153
return /^goroutine \d+ \[/.test(trimmed) || /\ngoroutine \d+ \[/.test(content);
114154
}
115155

156+
private async parseFormat0(blob: Blob, fileName: string): Promise<Result> {
157+
try {
158+
// Stream decompression - much cleaner!
159+
const decompressedStream = blob.stream().pipeThrough(new DecompressionStream('gzip'));
160+
const response = new Response(decompressedStream);
161+
const arrayBuffer = await response.arrayBuffer();
162+
const decodedData = new Uint8Array(arrayBuffer);
163+
164+
// Decode the pprof profile
165+
const profile = Profile.decode(decodedData);
166+
167+
// Convert pprof data to our internal format
168+
const groups: Group[] = [];
169+
170+
// Process samples - each sample represents a stack trace with count
171+
for (const sample of profile.sample) {
172+
const frames: Frame[] = [];
173+
const values = sample.value || [];
174+
const count = values.length > 0 ? Number(values[0]) : 1;
175+
176+
// Extract labels from the sample
177+
const labels: string[] = [];
178+
const stringTable = (profile.stringTable as any)?.strings || [];
179+
180+
if (sample.label) {
181+
for (const label of sample.label) {
182+
const key = stringTable[Number(label.key) || 0] || '';
183+
const value = stringTable[Number(label.str) || 0] || '';
184+
if (key && value) {
185+
labels.push(`${key}=${value}`);
186+
} else if (key) {
187+
labels.push(key);
188+
}
189+
}
190+
}
191+
192+
// Build stack trace from location IDs, skipping initial runtime frames
193+
let skipInitialRuntimeFrames = true;
194+
let lastSkippedRuntimeFrame: string | null = null;
195+
196+
for (const locationId of sample.locationId || []) {
197+
const location = profile.location.find(loc => loc.id === locationId);
198+
if (location) {
199+
for (const line of location.line || []) {
200+
const func = profile.function.find(f => f.id === line.functionId);
201+
if (func) {
202+
// String table access - the pprof format uses string table indexes
203+
const functionName = stringTable[Number(func.name) || 0] || 'unknown';
204+
const fileName = stringTable[Number(func.filename) || 0] || 'unknown';
205+
206+
// Skip initial runtime frames during parsing, but track the last one for label synthesis
207+
if (skipInitialRuntimeFrames && this.shouldSkipRuntimeFrame(functionName)) {
208+
lastSkippedRuntimeFrame = functionName;
209+
continue; // Skip this frame, don't allocate it
210+
}
211+
212+
// Once we find a non-runtime frame, stop skipping
213+
skipInitialRuntimeFrames = false;
214+
215+
frames.push({
216+
func: functionName,
217+
file: fileName,
218+
line: Number(line.line) || 0
219+
});
220+
}
221+
}
222+
}
223+
}
224+
225+
// Add synthesized label for the last skipped runtime frame
226+
if (lastSkippedRuntimeFrame) {
227+
const label = this.synthesizeRuntimeLabel(lastSkippedRuntimeFrame);
228+
if (label) {
229+
labels.push(label);
230+
}
231+
}
232+
233+
// Create group for this stack trace
234+
if (frames.length > 0) {
235+
const traceId = await fingerprint(frames);
236+
groups.push({
237+
traceId,
238+
count,
239+
labels,
240+
goroutines: [],
241+
trace: frames
242+
});
243+
}
244+
}
245+
246+
const result: ParsedFile = { originalName: fileName, groups };
247+
return { success: true, data: result };
248+
249+
} catch (error) {
250+
return {
251+
success: false,
252+
error: `Failed to parse pprof format: ${error instanceof Error ? error.message : String(error)}`
253+
};
254+
}
255+
}
256+
116257
private async parseFormat2(content: string, fileName: string): Promise<Result> {
117258
const lines = content.split('\n');
118259
const goroutineMap = new Map<string, boolean>(); // Track which goroutine IDs exist
@@ -267,7 +408,7 @@ export class FileParser {
267408
})
268409
);
269410

270-
const result: File = { originalName: fileName, groups };
411+
const result: ParsedFile = { originalName: fileName, groups };
271412
if (extractedName) {
272413
result.extractedName = extractedName;
273414
}
@@ -362,7 +503,7 @@ export class FileParser {
362503
groups.push({ traceId: await fingerprint(trace), count, labels, goroutines: [], trace });
363504
}
364505

365-
const result: File = { originalName: fileName, totalGoroutines, groups };
506+
const result: ParsedFile = { originalName: fileName, totalGoroutines, groups };
366507
if (extractedName) {
367508
result.extractedName = extractedName;
368509
}
@@ -385,4 +526,49 @@ export class FileParser {
385526

386527
return state;
387528
}
529+
530+
/**
531+
* Determine if a runtime frame should be skipped
532+
*/
533+
private shouldSkipRuntimeFrame(functionName: string): boolean {
534+
return functionName === 'runtime.gopark' ||
535+
functionName === 'runtime.goparkunlock' ||
536+
functionName === 'runtime.selectgo' ||
537+
functionName === 'runtime.chanrecv' ||
538+
functionName === 'runtime.chanrecv1' ||
539+
functionName === 'runtime.chanrecv2' ||
540+
functionName === 'runtime.chansend' ||
541+
functionName === 'runtime.semacquire' ||
542+
functionName === 'runtime.semacquire1' ||
543+
functionName === 'runtime.netpollblock' ||
544+
functionName === 'runtime.notetsleepg';
545+
}
546+
547+
/**
548+
* Synthesize a descriptive label for a skipped runtime frame
549+
*/
550+
private synthesizeRuntimeLabel(functionName: string): string | null {
551+
switch (functionName) {
552+
case 'runtime.chanrecv':
553+
case 'runtime.chanrecv1':
554+
case 'runtime.chanrecv2':
555+
return 'state=chan receive';
556+
case 'runtime.chansend':
557+
return 'state=chan send';
558+
case 'runtime.selectgo':
559+
return 'state=select';
560+
case 'runtime.gopark':
561+
case 'runtime.goparkunlock':
562+
return 'state=parked';
563+
case 'runtime.semacquire':
564+
case 'runtime.semacquire1':
565+
return 'state=semacquire';
566+
case 'runtime.netpollblock':
567+
return 'state=netpoll';
568+
case 'runtime.notetsleepg':
569+
return 'state=sleep';
570+
default:
571+
return null;
572+
}
573+
}
388574
}

src/parser/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,20 @@ export interface Group {
2525
trace: Frame[]; // Stack trace frames (common to both)
2626
}
2727

28-
export interface File {
28+
export interface ParsedFile {
2929
originalName: string;
3030
extractedName?: string; // Auto-extracted name during parsing (e.g., node ID)
3131
totalGoroutines?: number; // Total count if available (Format 1)
3232
groups: Group[]; // All stack groups
3333
}
3434

3535
// Result type that can represent success or failure
36-
export type Result = { success: true; data: File } | { success: false; error: string };
36+
export type Result = { success: true; data: ParsedFile } | { success: false; error: string };
3737

3838
// Zip extraction types
3939
export interface ZipFile {
4040
path: string;
41-
content: string;
41+
content: Blob;
4242
}
4343

4444
export interface ExtractResult {

src/parser/zip.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,7 @@ export class ZipHandler {
119119
outBlob = await new Response(stream).blob();
120120
}
121121

122-
const content = await await new Response(outBlob).text();
123-
files.push({ path: e.filename, content });
122+
files.push({ path: e.filename, content: outBlob });
124123
totalSize += e.uncompSize >>> 0;
125124
}
126125

0 commit comments

Comments
 (0)