Skip to content

Commit 53e2899

Browse files
authored
add 'emit' column-numbers mode using pprof-format Line.column (#382)
1 parent ee8559d commit 53e2899

5 files changed

Lines changed: 77 additions & 19 deletions

File tree

package-lock.json

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

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"license": "Apache-2.0",
3939
"dependencies": {
4040
"node-gyp-build": "^4.8.4",
41-
"pprof-format": "^2.2.1",
41+
"pprof-format": "^2.3.0",
4242
"source-map": "^0.8.0"
4343
},
4444
"devDependencies": {

ts/src/profile-serializer.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,18 @@ function isGeneratedLocation(
8787
* (`column << 32 | line`) — the encoding the Datadog deobfuscation backend
8888
* decodes (the same one the Chrome profile intake uses). Required to
8989
* deobfuscate single-line (bundled/minified) frames, where the column is the
90-
* only discriminator between functions.
91-
*
92-
* The enum leaves room for a future `'emit'` mode that would populate a real
93-
* pprof `Line.column` field once the backend consumes it.
90+
* only discriminator between functions. Only frames whose source map was
91+
* declared but missing locally are packed.
92+
* - `'emit'`: like `'pack'`, but records the column in the dedicated pprof
93+
* `Line.column` field instead of packing it into the line field, so the line
94+
* field stays standards-compliant and the two concerns are cleanly separated.
95+
* Scoped to the same frames as `'pack'` — only those whose source map was
96+
* declared but missing locally, i.e. the frames bound for server-side
97+
* unminification. The Datadog backend performs the `column << 32 | line`
98+
* packing itself (for Node.js profiles) when it consumes the column field.
99+
* Requires pprof-format >= 2.3.0, the version that added `Line.column`.
94100
*/
95-
export type ColumnNumbers = 'drop' | 'pack';
101+
export type ColumnNumbers = 'drop' | 'pack' | 'emit';
96102

97103
export const DEFAULT_COLUMN_NUMBERS: ColumnNumbers = 'drop';
98104

@@ -158,6 +164,7 @@ function serialize<T extends ProfileNode>(
158164
const functionIdMap = new Map<string, number>();
159165
const locationIdMap = new Map<string, number>();
160166
const packColumns = columnNumbers === 'pack';
167+
const emitColumns = columnNumbers === 'emit';
161168

162169
let hasMissingMapFiles = false;
163170

@@ -230,15 +237,23 @@ function serialize<T extends ProfileNode>(
230237
}
231238

232239
function getLine(loc: SourceLocation, scriptId?: number): Line {
233-
// Only pack the column for frames whose source map was declared but missing
234-
// locally — i.e. exactly the frames that will be sent for server-side
235-
// unminification (the same condition that sets dd:has-missing-map-files).
236-
// Locally-resolved frames keep their plain line, so packed values never
237-
// reach profiles that skip server-side unminification.
238-
const packColumn = packColumns && loc.missingMapFile === true;
240+
// Both 'pack' and 'emit' carry the column only for frames whose source map
241+
// was declared but missing locally — i.e. exactly the frames bound for
242+
// server-side unminification (the same condition that sets
243+
// dd:has-missing-map-files). Locally-resolved frames keep a plain line and
244+
// no column, so column data never reaches profiles that skip server-side
245+
// unminification.
246+
const carryColumn = loc.missingMapFile === true;
239247
return new Line({
240248
functionId: getFunction(loc, scriptId).id,
241-
line: packColumn ? packLineAndColumn(loc.line, loc.column) : loc.line,
249+
// 'pack' encodes the column into the high 32 bits of the line field.
250+
line:
251+
packColumns && carryColumn
252+
? packLineAndColumn(loc.line, loc.column)
253+
: loc.line,
254+
// 'emit' records the column in the dedicated pprof Line.column field
255+
// instead; the backend does the line/column packing itself.
256+
column: emitColumns && carryColumn ? loc.column : undefined,
242257
});
243258
}
244259

ts/src/time-profiler.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,9 @@ export interface TimeProfilerOptions {
115115
* Controls how frame column numbers are represented in the serialized
116116
* profile. Defaults to `'drop'` (column omitted) to preserve the historical
117117
* line-number semantics for existing consumers. Set to `'pack'` to pack the
118-
* column into the high 32 bits of the line field for backends that support
119-
* it (e.g. Datadog's JS/Node deobfuscation). See {@link ColumnNumbers}.
118+
* column into the high 32 bits of the line field, or `'emit'` to populate the
119+
* dedicated pprof `Line.column` field, for backends that support it (e.g.
120+
* Datadog's JS/Node deobfuscation). See {@link ColumnNumbers}.
120121
*/
121122
columnNumbers?: ColumnNumbers;
122123
}

ts/test/test-profile-serializer.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,48 @@ describe('profile-serializer', () => {
497497
);
498498
});
499499

500+
it('emits the real column in the pprof Line.column field and keeps the line plain when columnNumbers is "emit"', () => {
501+
// 'emit' never packs: the line field stays plain and the generated column
502+
// (1) is written to the dedicated pprof Line.column field. The backend
503+
// performs the line/column packing itself for Node.js profiles.
504+
const profile = serializeTimeProfile(
505+
makeSingleNodeTimeProfile(missingJsPath),
506+
1000,
507+
sourceMapper,
508+
false,
509+
undefined,
510+
[],
511+
'emit',
512+
);
513+
assertHasMissingMapToken(profile);
514+
const line = profile.location![0].line![0];
515+
assert.strictEqual(BigInt(line.line), 1n);
516+
assert.strictEqual(BigInt(line.column), 1n);
517+
});
518+
519+
it('does not carry a column under "emit" for a frame with no missing map', () => {
520+
// 'emit' is scoped to the same frames as 'pack': only those whose map was
521+
// declared but missing locally. With no source mapper the frame is neither
522+
// resolved nor flagged missing, so no column is emitted (Line.column
523+
// defaults to 0) and the line stays plain.
524+
const profile = serializeTimeProfile(
525+
makeSingleNodeTimeProfile(missingJsPath),
526+
1000,
527+
undefined,
528+
false,
529+
undefined,
530+
[],
531+
'emit',
532+
);
533+
assert.ok(
534+
!profile.comment || profile.comment.length === 0,
535+
'expected no missing-map token without a source mapper',
536+
);
537+
const line = profile.location![0].line![0];
538+
assert.strictEqual(BigInt(line.line), 1n);
539+
assert.strictEqual(BigInt(line.column), 0n);
540+
});
541+
500542
it('leaves a missing-map frame line plain under the default "drop"', () => {
501543
const profile = serializeTimeProfile(
502544
makeSingleNodeTimeProfile(missingJsPath),

0 commit comments

Comments
 (0)