From fb9bbc0d9a3b225c2c59d9caa3fd4b3298ef4cca Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 5 Aug 2026 16:51:10 +0200 Subject: [PATCH] feat: add 'emit' column-numbers mode using pprof-format Line.column Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 8 +++--- package.json | 2 +- ts/src/profile-serializer.ts | 39 ++++++++++++++++++--------- ts/src/time-profiler.ts | 5 ++-- ts/test/test-profile-serializer.ts | 42 ++++++++++++++++++++++++++++++ 5 files changed, 77 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index e617d4cd..6836830d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "Apache-2.0", "dependencies": { "node-gyp-build": "^4.8.4", - "pprof-format": "^2.2.1", + "pprof-format": "^2.3.0", "source-map": "^0.8.0" }, "devDependencies": { @@ -5062,9 +5062,9 @@ } }, "node_modules/pprof-format": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/pprof-format/-/pprof-format-2.2.1.tgz", - "integrity": "sha512-p4tVN7iK19ccDqQv8heyobzUmbHyds4N2FI6aBMcXz6y99MglTWDxIyhFkNaLeEXs6IFUEzT0zya0icbSLLY0g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pprof-format/-/pprof-format-2.3.0.tgz", + "integrity": "sha512-ovChRLoV4H3k0zKWq0AXewtnuPGskzBLjBPmF2N0DZu+H65PYuo51+6e/1GTb8Olm7oC0xvhhLdqPL4/XhCl4A==", "license": "MIT" }, "node_modules/prelude-ls": { diff --git a/package.json b/package.json index 99b522f1..8d4398ee 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "license": "Apache-2.0", "dependencies": { "node-gyp-build": "^4.8.4", - "pprof-format": "^2.2.1", + "pprof-format": "^2.3.0", "source-map": "^0.8.0" }, "devDependencies": { diff --git a/ts/src/profile-serializer.ts b/ts/src/profile-serializer.ts index 5f74d7a3..5d5ebb36 100644 --- a/ts/src/profile-serializer.ts +++ b/ts/src/profile-serializer.ts @@ -87,12 +87,18 @@ function isGeneratedLocation( * (`column << 32 | line`) — the encoding the Datadog deobfuscation backend * decodes (the same one the Chrome profile intake uses). Required to * deobfuscate single-line (bundled/minified) frames, where the column is the - * only discriminator between functions. - * - * The enum leaves room for a future `'emit'` mode that would populate a real - * pprof `Line.column` field once the backend consumes it. + * only discriminator between functions. Only frames whose source map was + * declared but missing locally are packed. + * - `'emit'`: like `'pack'`, but records the column in the dedicated pprof + * `Line.column` field instead of packing it into the line field, so the line + * field stays standards-compliant and the two concerns are cleanly separated. + * Scoped to the same frames as `'pack'` — only those whose source map was + * declared but missing locally, i.e. the frames bound for server-side + * unminification. The Datadog backend performs the `column << 32 | line` + * packing itself (for Node.js profiles) when it consumes the column field. + * Requires pprof-format >= 2.3.0, the version that added `Line.column`. */ -export type ColumnNumbers = 'drop' | 'pack'; +export type ColumnNumbers = 'drop' | 'pack' | 'emit'; export const DEFAULT_COLUMN_NUMBERS: ColumnNumbers = 'drop'; @@ -158,6 +164,7 @@ function serialize( const functionIdMap = new Map(); const locationIdMap = new Map(); const packColumns = columnNumbers === 'pack'; + const emitColumns = columnNumbers === 'emit'; let hasMissingMapFiles = false; @@ -230,15 +237,23 @@ function serialize( } function getLine(loc: SourceLocation, scriptId?: number): Line { - // Only pack the column for frames whose source map was declared but missing - // locally — i.e. exactly the frames that will be sent for server-side - // unminification (the same condition that sets dd:has-missing-map-files). - // Locally-resolved frames keep their plain line, so packed values never - // reach profiles that skip server-side unminification. - const packColumn = packColumns && loc.missingMapFile === true; + // Both 'pack' and 'emit' carry the column only for frames whose source map + // was declared but missing locally — i.e. exactly the frames bound for + // server-side unminification (the same condition that sets + // dd:has-missing-map-files). Locally-resolved frames keep a plain line and + // no column, so column data never reaches profiles that skip server-side + // unminification. + const carryColumn = loc.missingMapFile === true; return new Line({ functionId: getFunction(loc, scriptId).id, - line: packColumn ? packLineAndColumn(loc.line, loc.column) : loc.line, + // 'pack' encodes the column into the high 32 bits of the line field. + line: + packColumns && carryColumn + ? packLineAndColumn(loc.line, loc.column) + : loc.line, + // 'emit' records the column in the dedicated pprof Line.column field + // instead; the backend does the line/column packing itself. + column: emitColumns && carryColumn ? loc.column : undefined, }); } diff --git a/ts/src/time-profiler.ts b/ts/src/time-profiler.ts index 5c95e04f..d57b94fb 100644 --- a/ts/src/time-profiler.ts +++ b/ts/src/time-profiler.ts @@ -115,8 +115,9 @@ export interface TimeProfilerOptions { * Controls how frame column numbers are represented in the serialized * profile. Defaults to `'drop'` (column omitted) to preserve the historical * line-number semantics for existing consumers. Set to `'pack'` to pack the - * column into the high 32 bits of the line field for backends that support - * it (e.g. Datadog's JS/Node deobfuscation). See {@link ColumnNumbers}. + * column into the high 32 bits of the line field, or `'emit'` to populate the + * dedicated pprof `Line.column` field, for backends that support it (e.g. + * Datadog's JS/Node deobfuscation). See {@link ColumnNumbers}. */ columnNumbers?: ColumnNumbers; } diff --git a/ts/test/test-profile-serializer.ts b/ts/test/test-profile-serializer.ts index c2c0b085..5ad6d488 100644 --- a/ts/test/test-profile-serializer.ts +++ b/ts/test/test-profile-serializer.ts @@ -497,6 +497,48 @@ describe('profile-serializer', () => { ); }); + it('emits the real column in the pprof Line.column field and keeps the line plain when columnNumbers is "emit"', () => { + // 'emit' never packs: the line field stays plain and the generated column + // (1) is written to the dedicated pprof Line.column field. The backend + // performs the line/column packing itself for Node.js profiles. + const profile = serializeTimeProfile( + makeSingleNodeTimeProfile(missingJsPath), + 1000, + sourceMapper, + false, + undefined, + [], + 'emit', + ); + assertHasMissingMapToken(profile); + const line = profile.location![0].line![0]; + assert.strictEqual(BigInt(line.line), 1n); + assert.strictEqual(BigInt(line.column), 1n); + }); + + it('does not carry a column under "emit" for a frame with no missing map', () => { + // 'emit' is scoped to the same frames as 'pack': only those whose map was + // declared but missing locally. With no source mapper the frame is neither + // resolved nor flagged missing, so no column is emitted (Line.column + // defaults to 0) and the line stays plain. + const profile = serializeTimeProfile( + makeSingleNodeTimeProfile(missingJsPath), + 1000, + undefined, + false, + undefined, + [], + 'emit', + ); + assert.ok( + !profile.comment || profile.comment.length === 0, + 'expected no missing-map token without a source mapper', + ); + const line = profile.location![0].line![0]; + assert.strictEqual(BigInt(line.line), 1n); + assert.strictEqual(BigInt(line.column), 0n); + }); + it('leaves a missing-map frame line plain under the default "drop"', () => { const profile = serializeTimeProfile( makeSingleNodeTimeProfile(missingJsPath),