From 6a3e714a6f39473429957ace130877754d4106c0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:06:03 +0200 Subject: [PATCH 1/8] build(deps-dev): bump the patch-updates group with 2 updates (#373) Bumps the patch-updates group with 2 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [eslint-plugin-n](https://github.com/eslint-community/eslint-plugin-n). Updates `@types/node` from 26.1.0 to 26.1.1 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `eslint-plugin-n` from 18.2.1 to 18.2.2 - [Release notes](https://github.com/eslint-community/eslint-plugin-n/releases) - [Changelog](https://github.com/eslint-community/eslint-plugin-n/blob/master/CHANGELOG.md) - [Commits](https://github.com/eslint-community/eslint-plugin-n/compare/v18.2.1...v18.2.2) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 26.1.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: patch-updates - dependency-name: eslint-plugin-n dependency-version: 18.2.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: patch-updates ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 92895625..b7ec24b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,14 +15,14 @@ }, "devDependencies": { "@types/mocha": "^10.0.1", - "@types/node": "26.1.0", + "@types/node": "26.1.1", "@types/semver": "^7.5.8", "@types/sinon": "^21.0.1", "@types/tmp": "^0.2.3", "clang-format": "^1.8.0", "codecov": "^3.8.3", "deep-copy": "^1.4.2", - "eslint-plugin-n": "^18.2.1", + "eslint-plugin-n": "^18.2.2", "gts": "^7.0.0", "js-green-licenses": "^4.0.0", "mocha": "^11.7.6", @@ -962,9 +962,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", - "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", "dependencies": { @@ -2245,9 +2245,9 @@ } }, "node_modules/eslint-plugin-n": { - "version": "18.2.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-18.2.1.tgz", - "integrity": "sha512-aO3C9//yq8JIvYOi/T+jPvcZ9hZzpwzbR8esrYpFtgE9vpbyM8kn42AQOtIqYspVmpaSWr8X+nrlQuAJYxXAaw==", + "version": "18.2.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-18.2.2.tgz", + "integrity": "sha512-gOO0lIqwEjZ750kv9/SptCWArUoAZXJoBr0vYWTO2dCBxctHUXlBIigiC8xuxxr/NKqgIT6Ehz1xRcilj8a5cA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 6d60302a..0d74cf62 100644 --- a/package.json +++ b/package.json @@ -43,14 +43,14 @@ }, "devDependencies": { "@types/mocha": "^10.0.1", - "@types/node": "26.1.0", + "@types/node": "26.1.1", "@types/semver": "^7.5.8", "@types/sinon": "^21.0.1", "@types/tmp": "^0.2.3", "clang-format": "^1.8.0", "codecov": "^3.8.3", "deep-copy": "^1.4.2", - "eslint-plugin-n": "^18.2.1", + "eslint-plugin-n": "^18.2.2", "gts": "^7.0.0", "js-green-licenses": "^4.0.0", "mocha": "^11.7.6", From 665f3deade724a938fdfbf8991f60f52d59a6a43 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Fri, 17 Jul 2026 11:55:55 +0200 Subject: [PATCH 2/8] otel-thread-ctx: publish V8 + ObjectWrap layout constants for readers (#375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * otel-thread-ctx: publish V8 + ObjectWrap layout constants for readers Extends the OTEP-4719 process-context attributes emitted by getProcessContextAttributes with three new entries an out-of-process eBPF reader needs to walk from our TLS discovery struct to the actual OTEP-4947 record: - threadlocal.native_wrap_fields_offset — sizeof(node::ObjectWrap). Given a pointer to a CtxWrap fetched via wrapped_object_offset, add this to reach CtxWrap::record_. - threadlocal.js_map_table_offset — offset within a V8 JSMap object of the tagged pointer to its backing OrderedHashMap table (JSCollection::kTableOffset in V8, 0x18). - threadlocal.ordered_hash_map_header_size — size of the header preceding the element_count / deleted_element_count / n_buckets fields inside an OrderedHashMap (0x10). Without these, a reader implementing the Node.js schema would have to hardcode them itself and re-verify them per V8 version. Keeping the constants in the addon puts one source of truth close to V8. The two V8 internal offsets are not exposed in V8's public headers (v8-internal.h); they live in Node's private V8 tree (deps/v8/src/objects/{js-collection,ordered-hash-table}.h) — we cite those paths in the code comments so a future V8 layout change has a clear pointer to what to re-check. New non-Linux fallback values match the Linux ones (24 / 0x18 / 0x10); the reader contract is Linux-only per the OTEP anyway, so this just keeps the exported surface consistent in shape across platforms. --- bindings/otel-thread-ctx.cc | 46 +++++++++++++++++++++++++-------- ts/src/otel-thread-ctx.ts | 15 +++++++++++ ts/test/test-otel-thread-ctx.ts | 9 +++++++ 3 files changed, 59 insertions(+), 11 deletions(-) diff --git a/bindings/otel-thread-ctx.cc b/bindings/otel-thread-ctx.cc index 574a87cf..c98bb209 100644 --- a/bindings/otel-thread-ctx.cc +++ b/bindings/otel-thread-ctx.cc @@ -679,6 +679,28 @@ constexpr int WRAPPED_OBJECT_OFFSET = 0; #endif constexpr int TAGGED_SIZE = v8::internal::kApiTaggedSize; +// sizeof(node::ObjectWrap). Given a pointer to a CtxWrap — or any other +// ObjectWrap-derived C++ object attached to a JSObject via the V8 +// wrapped-object slot — add this offset to reach the derived class's own +// fields. For CtxWrap, that's `record_` (see the static_assert on its +// offset above). +constexpr int NATIVE_WRAP_FIELDS_OFFSET = + static_cast(sizeof(node::ObjectWrap)); + +// V8 JSMap layout: kTableOffset within the JSMap object holds a tagged +// pointer to the backing OrderedHashMap table. Not exposed in V8's +// public headers; kept in sync with +// deps/v8/src/objects/js-collection.h (JSCollection::kTableOffset) +// and the torque-generated JSCollection layout. +constexpr int JS_MAP_TABLE_OFFSET = 0x18; + +// V8 OrderedHashMap layout: the on-heap table starts with a 16-byte +// header before the element_count / deleted_element_count / +// number_of_buckets fields. Not exposed in V8's public headers; kept in +// sync with deps/v8/src/objects/ordered-hash-table.h +// (OrderedHashTable base layout). +constexpr int ORDERED_HASH_MAP_HEADER_SIZE = 0x10; + } // namespace void OtelThreadCtx::Init(Local exports) { @@ -688,17 +710,19 @@ void OtelThreadCtx::Init(Local exports) { Isolate* isolate = Isolate::GetCurrent(); Local ctx = isolate->GetCurrentContext(); - exports - ->Set(ctx, - String::NewFromUtf8Literal(isolate, - "otelThreadCtxWrappedObjectOffset"), - Integer::New(isolate, WRAPPED_OBJECT_OFFSET)) - .FromJust(); - exports - ->Set(ctx, - String::NewFromUtf8Literal(isolate, "otelThreadCtxTaggedSize"), - Integer::New(isolate, TAGGED_SIZE)) - .FromJust(); + auto publish_int = [&](const char* name, int value) { + exports + ->Set(ctx, + String::NewFromUtf8(isolate, name).ToLocalChecked(), + Integer::New(isolate, value)) + .FromJust(); + }; + publish_int("otelThreadCtxJsMapTableOffset", JS_MAP_TABLE_OFFSET); + publish_int("otelThreadCtxNativeWrapFieldsOffset", NATIVE_WRAP_FIELDS_OFFSET); + publish_int("otelThreadCtxOrderedHashMapHeaderSize", + ORDERED_HASH_MAP_HEADER_SIZE); + publish_int("otelThreadCtxTaggedSize", TAGGED_SIZE); + publish_int("otelThreadCtxWrappedObjectOffset", WRAPPED_OBJECT_OFFSET); } } // namespace dd diff --git a/ts/src/otel-thread-ctx.ts b/ts/src/otel-thread-ctx.ts index 316e1505..cbc91c93 100644 --- a/ts/src/otel-thread-ctx.ts +++ b/ts/src/otel-thread-ctx.ts @@ -40,6 +40,9 @@ export interface ProcessContextAttributes { readonly 'threadlocal.attribute_key_map': readonly string[]; readonly 'threadlocal.wrapped_object_offset': number; readonly 'threadlocal.tagged_size': number; + readonly 'threadlocal.native_wrap_fields_offset': number; + readonly 'threadlocal.js_map_table_offset': number; + readonly 'threadlocal.ordered_hash_map_header_size': number; } /** @@ -103,6 +106,9 @@ interface Addon { otelThreadCtxGetStoredAlsHash(): number; otelThreadCtxWrappedObjectOffset: number; otelThreadCtxTaggedSize: number; + otelThreadCtxNativeWrapFieldsOffset: number; + otelThreadCtxJsMapTableOffset: number; + otelThreadCtxOrderedHashMapHeaderSize: number; } const SCHEMA_VERSION = 'nodejs_v1_dev'; @@ -114,6 +120,9 @@ const SCHEMA_VERSION = 'nodejs_v1_dev'; // consistent in shape. let WRAPPED_OBJECT_OFFSET = 24; let TAGGED_SIZE = 8; +let NATIVE_WRAP_FIELDS_OFFSET = 24; +let JS_MAP_TABLE_OFFSET = 0x18; +let ORDERED_HASH_MAP_HEADER_SIZE = 0x10; /** {@inheritDoc ThreadContextCtor} */ export let ThreadContext: ThreadContextCtor; @@ -140,6 +149,9 @@ if (process.platform === 'linux') { const addon: Addon = findBinding(join(__dirname, '..', '..')); WRAPPED_OBJECT_OFFSET = addon.otelThreadCtxWrappedObjectOffset; TAGGED_SIZE = addon.otelThreadCtxTaggedSize; + NATIVE_WRAP_FIELDS_OFFSET = addon.otelThreadCtxNativeWrapFieldsOffset; + JS_MAP_TABLE_OFFSET = addon.otelThreadCtxJsMapTableOffset; + ORDERED_HASH_MAP_HEADER_SIZE = addon.otelThreadCtxOrderedHashMapHeaderSize; ThreadContext = addon.threadContext; @@ -266,5 +278,8 @@ export function getProcessContextAttributes( 'threadlocal.attribute_key_map': Object.freeze(keys.slice()), 'threadlocal.wrapped_object_offset': WRAPPED_OBJECT_OFFSET, 'threadlocal.tagged_size': TAGGED_SIZE, + 'threadlocal.native_wrap_fields_offset': NATIVE_WRAP_FIELDS_OFFSET, + 'threadlocal.js_map_table_offset': JS_MAP_TABLE_OFFSET, + 'threadlocal.ordered_hash_map_header_size': ORDERED_HASH_MAP_HEADER_SIZE, }) as ProcessContextAttributes; } diff --git a/ts/test/test-otel-thread-ctx.ts b/ts/test/test-otel-thread-ctx.ts index 070cf5c0..f6229f2c 100644 --- a/ts/test/test-otel-thread-ctx.ts +++ b/ts/test/test-otel-thread-ctx.ts @@ -733,8 +733,17 @@ function captureBytes(opts: { strictAssert.deepEqual(pca['threadlocal.attribute_key_map'], keys); strictAssert.equal(pca['threadlocal.wrapped_object_offset'], 24); strictAssert.equal(pca['threadlocal.tagged_size'], 8); + strictAssert.equal(pca['threadlocal.native_wrap_fields_offset'], 24); + strictAssert.equal(pca['threadlocal.js_map_table_offset'], 0x18); + strictAssert.equal( + pca['threadlocal.ordered_hash_map_header_size'], + 0x10, + ); strictAssert.deepEqual(Object.keys(pca).sort(), [ 'threadlocal.attribute_key_map', + 'threadlocal.js_map_table_offset', + 'threadlocal.native_wrap_fields_offset', + 'threadlocal.ordered_hash_map_header_size', 'threadlocal.schema_version', 'threadlocal.tagged_size', 'threadlocal.wrapped_object_offset', From 28ecc557073f642fda934416d1d23091bdfeb366 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:34:21 +0200 Subject: [PATCH 3/8] build(deps): bump the minor-updates group with 3 updates (#376) Bumps the minor-updates group with 3 updates: [source-map](https://github.com/mozilla/source-map), [sinon](https://github.com/sinonjs/sinon) and [@types/sinon](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/sinon). Updates `source-map` from 0.7.6 to 0.8.0 - [Release notes](https://github.com/mozilla/source-map/releases) - [Changelog](https://github.com/mozilla/source-map/blob/master/CHANGELOG.md) - [Commits](https://github.com/mozilla/source-map/compare/0.7.6...v0.8.0) Updates `sinon` from 22.0.0 to 22.1.0 - [Release notes](https://github.com/sinonjs/sinon/releases) - [Changelog](https://github.com/sinonjs/sinon/blob/main/CHANGES.md) - [Commits](https://github.com/sinonjs/sinon/compare/v22.0.0...v22.1.0) Updates `@types/sinon` from 21.0.1 to 22.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/sinon) --- updated-dependencies: - dependency-name: source-map dependency-version: 0.8.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-updates - dependency-name: sinon dependency-version: 22.1.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor-updates - dependency-name: "@types/sinon" dependency-version: 22.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: minor-updates ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 24 ++++++++++++------------ package.json | 6 +++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index b7ec24b3..67f5418f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,13 @@ "dependencies": { "node-gyp-build": "^4.8.4", "pprof-format": "^2.2.1", - "source-map": "^0.7.4" + "source-map": "^0.8.0" }, "devDependencies": { "@types/mocha": "^10.0.1", "@types/node": "26.1.1", "@types/semver": "^7.5.8", - "@types/sinon": "^21.0.1", + "@types/sinon": "^22.0.0", "@types/tmp": "^0.2.3", "clang-format": "^1.8.0", "codecov": "^3.8.3", @@ -29,7 +29,7 @@ "nan": "^2.28.0", "nyc": "^18.0.0", "semver": "^7.8.5", - "sinon": "^22.0.0", + "sinon": "^22.1.0", "source-map-support": "^0.5.21", "tmp": "0.2.7", "typescript": "^6.0.3" @@ -996,9 +996,9 @@ "license": "MIT" }, "node_modules/@types/sinon": { - "version": "21.0.1", - "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-21.0.1.tgz", - "integrity": "sha512-5yoJSqLbjH8T9V2bksgRayuhpZy+723/z6wBOR+Soe4ZlXC0eW8Na71TeaZPUWDQvM7LYKa9UGFc6LRqxiR5fQ==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-22.0.0.tgz", + "integrity": "sha512-TDbVpbccc2HfiqHR09Argj3mHV1KMW7sCCKj52fsl8lbRLkEn7fB1966EWhOKWUBcqfBueZuPoA7/OK1CKiy3g==", "dev": true, "license": "MIT", "dependencies": { @@ -5701,9 +5701,9 @@ "license": "ISC" }, "node_modules/sinon": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.0.0.tgz", - "integrity": "sha512-sq/6DpdXOrLyfbKlXLg/Usc7xu8YXPeLkOFZRvA3bNUSA2lhbrZ06yuXbH1fkzBPCbz9O10+7hznzUsjaYNm0Q==", + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-22.1.0.tgz", + "integrity": "sha512-n1ajF2rBWMTtEwbKcw4UdFg4nCnDdq/U6RDoxtOd7oapOlRoJ5ynwFx60owROyhDpA9QhMZi0pCO/xtmwFjG7w==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5728,9 +5728,9 @@ } }, "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", "license": "BSD-3-Clause", "engines": { "node": ">= 12" diff --git a/package.json b/package.json index 0d74cf62..2277aca8 100644 --- a/package.json +++ b/package.json @@ -39,13 +39,13 @@ "dependencies": { "node-gyp-build": "^4.8.4", "pprof-format": "^2.2.1", - "source-map": "^0.7.4" + "source-map": "^0.8.0" }, "devDependencies": { "@types/mocha": "^10.0.1", "@types/node": "26.1.1", "@types/semver": "^7.5.8", - "@types/sinon": "^21.0.1", + "@types/sinon": "^22.0.0", "@types/tmp": "^0.2.3", "clang-format": "^1.8.0", "codecov": "^3.8.3", @@ -57,7 +57,7 @@ "nan": "^2.28.0", "nyc": "^18.0.0", "semver": "^7.8.5", - "sinon": "^22.0.0", + "sinon": "^22.1.0", "source-map-support": "^0.5.21", "tmp": "0.2.7", "typescript": "^6.0.3" From 57b977cf92351200cd5a23f3cb2885ca276cef70 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 22 Jul 2026 13:37:44 +0200 Subject: [PATCH 4/8] test: document webpack source map behaviour for column=0 lookups (#299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: document webpack source map behaviour for column=0 lookups (#248) Add tests for SourceMapper.mappingInfo with a synthetic webpack-style single-line bundle to document the known limitation introduced by #248. Background ---------- PR #81 changed originalPositionFor to always try LEAST_UPPER_BOUND first (then fall back to GREATEST_LOWER_BOUND). This was reverted in #106 because it broke webpack source maps: for real non-zero columns LEAST_UPPER_BOUND finds the *next* mapping (≥ column) rather than the one at the column, returning wrong function names. PR #248 fixed that regression by using LEAST_UPPER_BOUND only when column === 0, and GREATEST_LOWER_BOUND otherwise. This correctly handles Node.js ≥ 25 where V8's LineTick struct carries real column numbers. Residual limitation (Node.js < 25) ----------------------------------- On Node.js < 25, the LineTick struct has no column field. The C++ layer therefore always emits column=0 for every LineTick sample. With column=0, the sourcemapper uses LEAST_UPPER_BOUND, which finds the *first* mapping on the line. In a webpack bundle (all output on one line) every function maps to the same first source function in the map. This is not a regression vs. the pre-#248 state: before #248, those functions were simply unmapped (column=0 + GREATEST_LOWER_BOUND → nothing ≤ 0 → null → falls back to generated name/file). Both outcomes are imperfect; #248 trades "unmapped" for "mapped to first function", which may or may not be preferable depending on the use case. The two new tests pin both behaviours explicitly so any future change to this logic is immediately visible. Co-Authored-By: Claude Sonnet 4.6 * fix: apply prettier formatting to test-sourcemapper.ts * Fix source mapping of zero-column locations (#248) When lineNumbers is enabled, the column is always zero. If only one occurence of a call occurs on one line then that is correctly selected. However, in cases where the same function is called multiple times in the same line it will be unable to differentiate them and would use the unmapped value. This now makes it select the first call in the line as a best-guess for the match, and in Node.js v25 will use the new column field in LineTick to select the correct column where possible. * fix: use path.resolve/join for platform-portable bundle path in test --------- Co-authored-by: Stephen Belanger --- ts/test/test-sourcemapper.ts | 115 +++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/ts/test/test-sourcemapper.ts b/ts/test/test-sourcemapper.ts index d1d6801d..c85ed019 100644 --- a/ts/test/test-sourcemapper.ts +++ b/ts/test/test-sourcemapper.ts @@ -17,6 +17,7 @@ import * as assert from 'assert'; import * as fs from 'fs'; import * as path from 'path'; import * as tmp from 'tmp'; +import * as sourceMap from 'source-map'; import { ANNOTATION_TAIL_BYTES, @@ -356,3 +357,117 @@ describe('SourceMapper.loadDirectory', () => { ); }); }); + +// Regression test for the webpack source map issue originally surfaced in #81 +// and relevant to #248. +// +// Webpack minifies output into a single line, placing multiple functions at +// different columns. On Node.js < 25, V8's LineTick struct has no column +// field, so the C++ layer always emits column=0 for every sample. The +// sourcemapper's LEAST_UPPER_BOUND path (triggered when column===0) then finds +// the first mapping on the line for every lookup — attributing all functions in +// the bundle to whichever source function appears first. +// +// On Node.js >= 25, V8 fills in real column numbers, so each function is +// looked up with GREATEST_LOWER_BOUND and resolves correctly. +// +// This test documents both behaviours so a regression would be immediately +// visible. +describe('SourceMapper.mappingInfo — webpack-style single-line bundle', () => { + const MAP_DIR = path.resolve('app', 'dist'); + const BUNDLE_PATH = path.join(MAP_DIR, 'bundle.js'); + + // Build a source map that places three functions on line 1 of bundle.js at + // columns 10, 30 and 50, each originating from a different source file. + async function buildMapper(): Promise { + const gen = new sourceMap.SourceMapGenerator({file: 'bundle.js'}); + + // funcA — bundle.js line 1, col 10 → a.ts line 1, col 0 + gen.addMapping({ + generated: {line: 1, column: 10}, + source: 'a.ts', + original: {line: 1, column: 0}, + name: 'funcA', + }); + // funcB — bundle.js line 1, col 30 → b.ts line 1, col 0 + gen.addMapping({ + generated: {line: 1, column: 30}, + source: 'b.ts', + original: {line: 1, column: 0}, + name: 'funcB', + }); + // funcC — bundle.js line 1, col 50 → c.ts line 1, col 0 + gen.addMapping({ + generated: {line: 1, column: 50}, + source: 'c.ts', + original: {line: 1, column: 0}, + name: 'funcC', + }); + + const consumer = (await new sourceMap.SourceMapConsumer( + gen.toJSON() as unknown as sourceMap.RawSourceMap, + )) as unknown as sourceMap.RawSourceMap; + + const mapper = new SourceMapper(); + mapper.infoMap.set(BUNDLE_PATH, { + mapFileDir: MAP_DIR, + mapConsumer: consumer, + }); + return mapper; + } + + // Helper: look up a location in bundle.js. + function lookup(mapper: SourceMapper, line: number, column: number) { + return mapper.mappingInfo({ + file: BUNDLE_PATH, + line, + column, + name: 'unknown', + }); + } + + it('resolves functions correctly when real column numbers are available (Node.js ≥ 25 behaviour)', async () => { + // When V8 supplies real 1-based columns (11, 31, 51 for cols 10, 30, 50) + // GREATEST_LOWER_BOUND is used and each function maps to its own source. + const mapper = await buildMapper(); + + const a = lookup(mapper, 1, 11); // col 11 → adjusted 10 → funcA + assert.strictEqual(a.name, 'funcA', 'funcA column'); + assert.ok(a.file!.endsWith('a.ts'), `funcA file: ${a.file}`); + + const b = lookup(mapper, 1, 31); // col 31 → adjusted 30 → funcB + assert.strictEqual(b.name, 'funcB', 'funcB column'); + assert.ok(b.file!.endsWith('b.ts'), `funcB file: ${b.file}`); + + const c = lookup(mapper, 1, 51); // col 51 → adjusted 50 → funcC + assert.strictEqual(c.name, 'funcC', 'funcC column'); + assert.ok(c.file!.endsWith('c.ts'), `funcC file: ${c.file}`); + }); + + it('resolves column=0 to the first mapping on the line (Node.js < 25 behaviour — known limitation)', async () => { + // On Node.js < 25, LineTick has no column field so the C++ layer emits + // column=0 for every sample. LEAST_UPPER_BOUND is therefore used and all + // three functions resolve to the *first* mapping on the line (funcA at + // column 10). This is a known limitation: distinct functions in a + // webpack bundle cannot be differentiated on pre-25 Node.js. + const mapper = await buildMapper(); + + // All three functions are reported with column=0 (no real column info). + const a = lookup(mapper, 1, 0); + const b = lookup(mapper, 1, 0); + const c = lookup(mapper, 1, 0); + + // They all resolve to the first mapped function on the line. + assert.strictEqual(a.name, 'funcA', 'funcA with column=0'); + assert.strictEqual( + b.name, + 'funcA', + 'funcB with column=0 maps to funcA — known limitation', + ); + assert.strictEqual( + c.name, + 'funcA', + 'funcC with column=0 maps to funcA — known limitation', + ); + }); +}); From e82f6b4f9db8c79ab8d9326a9081ce6f3b7fee28 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 22 Jul 2026 13:48:28 +0200 Subject: [PATCH 5/8] feat: add columnNumbers option to control frame column encoding (#377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add columnNumbers option to control frame column encoding pprof's Line message historically had no column field, so a frame's column was dropped from the serialized profile. Add a columnNumbers option controlling how the column is represented: - 'drop' (default): omit the column, emit the plain line — preserves the historical behavior, so existing pprof-nodejs consumers are unaffected. - 'pack': pack the column into the high 32 bits of the line field (column << 32 | line) — the encoding the Datadog deobfuscation backend decodes (matching the Chrome profile intake). This lets Node.js profiles of bundled/minified single-line server code (e.g. Next.js server chunks) be deobfuscated — the column is the only discriminator between functions on a single generated line. The enum leaves room for a future 'emit' mode using a real pprof Line.column field. Threaded through serializeTimeProfile/serializeHeapProfile and the time profiler start options (default 'drop'); dd-trace-js opts in with columnNumbers: 'pack'. * fix: thread columnNumbers through the heap profiler; clarify BigInt comment Address review feedback on #377: - serializeHeapProfile accepted the columnNumbers option but nothing passed it; thread it through heap.profile / heap.profileV2 / convertProfile so heap profiles can opt into packing too (default 'drop'). Add a heap 'pack' test. - Fix the misleading packLineAndColumn comment: `column << 32` on a plain number is a no-op (32-bit shift) returning `column`; BigInt is needed because the arithmetic form loses precision past 2**53. --- ts/src/heap-profiler.ts | 19 +++++- ts/src/profile-serializer.ts | 53 ++++++++++++++- ts/src/time-profiler.ts | 16 +++++ ts/test/test-profile-serializer.ts | 106 +++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 3 deletions(-) diff --git a/ts/src/heap-profiler.ts b/ts/src/heap-profiler.ts index d22516c3..1d00d422 100644 --- a/ts/src/heap-profiler.ts +++ b/ts/src/heap-profiler.ts @@ -23,7 +23,11 @@ import { stopSamplingHeapProfiler, monitorOutOfMemory as monitorOutOfMemoryImported, } from './heap-profiler-bindings'; -import {serializeHeapProfile} from './profile-serializer'; +import { + ColumnNumbers, + DEFAULT_COLUMN_NUMBERS, + serializeHeapProfile, +} from './profile-serializer'; import {SourceMapper} from './sourcemapper/sourcemapper'; import { AllocationProfileNode, @@ -86,12 +90,14 @@ export function profile( ignoreSamplePath?: string, sourceMapper?: SourceMapper, generateLabels?: GenerateAllocationLabelsFunction, + columnNumbers: ColumnNumbers = DEFAULT_COLUMN_NUMBERS, ): Profile { const result = convertProfile( v8Profile(), ignoreSamplePath, sourceMapper, generateLabels, + columnNumbers, ); // In allocation mode V8 keeps every sampled object (live + collected-by-GC) // in an append-only buffer that's only freed by stopSamplingHeapProfiler. @@ -110,6 +116,7 @@ export function convertProfile( ignoreSamplePath?: string, sourceMapper?: SourceMapper, generateLabels?: GenerateAllocationLabelsFunction, + columnNumbers: ColumnNumbers = DEFAULT_COLUMN_NUMBERS, ): Profile { const allocations = startedWithAllocations; const startTimeNanos = Date.now() * 1000 * 1000; @@ -160,6 +167,7 @@ export function convertProfile( sourceMapper, generateLabels, allocations, + columnNumbers, ); } @@ -175,9 +183,16 @@ export function profileV2( ignoreSamplePath?: string, sourceMapper?: SourceMapper, generateLabels?: GenerateAllocationLabelsFunction, + columnNumbers: ColumnNumbers = DEFAULT_COLUMN_NUMBERS, ): Profile { return v8ProfileV2(root => { - return convertProfile(root, ignoreSamplePath, sourceMapper, generateLabels); + return convertProfile( + root, + ignoreSamplePath, + sourceMapper, + generateLabels, + columnNumbers, + ); }); } diff --git a/ts/src/profile-serializer.ts b/ts/src/profile-serializer.ts index d3378c29..3435dd1b 100644 --- a/ts/src/profile-serializer.ts +++ b/ts/src/profile-serializer.ts @@ -78,6 +78,49 @@ function isGeneratedLocation( ); } +/** + * Controls how a frame's column number is represented in the emitted profile. + * - `'drop'`: omit the column; only the line number is emitted. This is the + * default and matches the historical behavior and standard pprof line + * semantics, so existing consumers see unchanged line numbers. + * - `'pack'`: pack the column into the high 32 bits of the line field + * (`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. + */ +export type ColumnNumbers = 'drop' | 'pack'; + +export const DEFAULT_COLUMN_NUMBERS: ColumnNumbers = 'drop'; + +/** + * Packs a frame's line and column into the single value carried by the pprof + * Line.line field: the column in the high 32 bits, the line in the low 32 bits + * (matching the backend's JavaScriptProfileUnminifier.decodeLineAndColumn). + * + * BigInt is required: JavaScript's bitwise operators are 32-bit, so `column << + * 32` on a plain number is a no-op that returns `column` unchanged. Computing + * the pack arithmetically as `column * 2**32 + line` instead loses integer + * precision once the column exceeds 2**21 (the product passes 2**53, Number's + * safe-integer limit) — and minified single-line bundles routinely have columns + * that large. + */ +function packLineAndColumn( + line: number | undefined, + column: number | undefined, +): number | bigint { + if (!line) { + return 0; + } + if (!column) { + return line; + } + return (BigInt(column) << 32n) | BigInt(line); +} + function requireAllocationStat( value: number | undefined, field: string, @@ -107,6 +150,7 @@ function serialize( stringTable: StringTable, ignoreSamplesPath?: string, sourceMapper?: SourceMapper, + columnNumbers: ColumnNumbers = DEFAULT_COLUMN_NUMBERS, ) { const samples: Sample[] = []; const locations: Location[] = []; @@ -187,7 +231,10 @@ function serialize( function getLine(loc: SourceLocation, scriptId?: number): Line { return new Line({ functionId: getFunction(loc, scriptId).id, - line: loc.line, + line: + columnNumbers === 'pack' + ? packLineAndColumn(loc.line, loc.column) + : loc.line, }); } @@ -386,6 +433,7 @@ export function serializeTimeProfile( recomputeSamplingInterval = false, generateLabels?: GenerateTimeLabelsFunction, lowCardinalityLabels: string[] = [], + columnNumbers: ColumnNumbers = DEFAULT_COLUMN_NUMBERS, ): Profile { // If requested, recompute sampling interval from profile duration and total number of hits, // since profile duration should be #hits x interval. @@ -509,6 +557,7 @@ export function serializeTimeProfile( stringTable, undefined, sourceMapper, + columnNumbers, ); return new Profile(profile); @@ -556,6 +605,7 @@ export function serializeHeapProfile( sourceMapper?: SourceMapper, generateLabels?: GenerateAllocationLabelsFunction, allocations = false, + columnNumbers: ColumnNumbers = DEFAULT_COLUMN_NUMBERS, ): Profile { const appendHeapEntryToSamples: AppendEntryToSamples< AllocationProfileNode | AllocationProfileNodeWithStats @@ -642,6 +692,7 @@ export function serializeHeapProfile( stringTable, ignoreSamplesPath, sourceMapper, + columnNumbers, ); return new Profile(profile); diff --git a/ts/src/time-profiler.ts b/ts/src/time-profiler.ts index d5b465b6..5c95e04f 100644 --- a/ts/src/time-profiler.ts +++ b/ts/src/time-profiler.ts @@ -18,6 +18,8 @@ import {setTimeout} from 'timers/promises'; import {Profile} from 'pprof-format'; import { + ColumnNumbers, + DEFAULT_COLUMN_NUMBERS, serializeTimeProfile, GARBAGE_COLLECTION_FUNCTION_NAME, NON_JS_THREADS_FUNCTION_NAME, @@ -54,6 +56,7 @@ let gProfiler: NativeTimeProfiler | undefined; let gStore: AsyncLocalStorage | undefined; let gSourceMapper: SourceMapper | undefined; let gIntervalMicros: Microseconds; +let gColumnNumbers: ColumnNumbers = DEFAULT_COLUMN_NUMBERS; let gV8ProfilerStuckEventLoopDetected = 0; function handleStopRestart() { @@ -107,6 +110,15 @@ export interface TimeProfilerOptions { collectCpuTime?: boolean; collectAsyncId?: boolean; useCPED?: boolean; + + /** + * 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}. + */ + columnNumbers?: ColumnNumbers; } const DEFAULT_OPTIONS: TimeProfilerOptions = { @@ -118,6 +130,7 @@ const DEFAULT_OPTIONS: TimeProfilerOptions = { collectCpuTime: false, collectAsyncId: false, useCPED: false, + columnNumbers: DEFAULT_COLUMN_NUMBERS, }; export async function profile( @@ -147,6 +160,7 @@ export function start(options: TimeProfilerOptions = {}) { gProfiler = new TimeProfiler({...options, CPEDKey: store, isMainThread}); gSourceMapper = options.sourceMapper; gIntervalMicros = options.intervalMicros!; + gColumnNumbers = options.columnNumbers ?? DEFAULT_COLUMN_NUMBERS; gV8ProfilerStuckEventLoopDetected = 0; gProfiler.start(); @@ -183,6 +197,7 @@ export function stop( true, generateLabels, lowCardinalityLabels, + gColumnNumbers, ); if (!restart) { @@ -220,6 +235,7 @@ export function stopV2( true, generateLabels, lowCardinalityLabels, + gColumnNumbers, ), ); if (restart) { diff --git a/ts/test/test-profile-serializer.ts b/ts/test/test-profile-serializer.ts index 0e40e99b..d4c7b9ea 100644 --- a/ts/test/test-profile-serializer.ts +++ b/ts/test/test-profile-serializer.ts @@ -82,6 +82,85 @@ describe('profile-serializer', () => { assert.deepEqual(timeProfileOut, timeProfile); }); + it('should drop the column by default and pack it when columnNumbers is "pack"', () => { + // Regression test for SCP-1293: a bundled/minified module (e.g. a + // Next.js server chunk) emits every function onto generated line 1, so + // the column is the only discriminator between functions. The pprof Line + // message carries no dedicated column, so with columnNumbers='pack' the + // column is packed into the high 32 bits (column<<32 | line) — the + // encoding the Datadog deobfuscation backend decodes. The default 'drop' + // must leave the plain line untouched for backwards compatibility. + const alphaLeaf = { + name: 'alpha', + scriptName: '/opt/app/.next/server/chunks/58844.js', + scriptId: 1, + lineNumber: 1, + columnNumber: 15665, + hitCount: 3, + children: [], + }; + const betaLeaf = { + name: 'beta', + scriptName: '/opt/app/.next/server/chunks/58844.js', + scriptId: 1, + lineNumber: 1, + columnNumber: 14349, + hitCount: 2, + children: [], + }; + const bundleProfile: TimeProfile = { + startTime: 0, + endTime: 10 * 1000 * 1000, + hasCpuTime: true, + nonJSThreadsCpuTime: 0, + topDownRoot: { + name: '(root)', + scriptName: 'root', + scriptId: 0, + lineNumber: 0, + columnNumber: 0, + hitCount: 0, + children: [alphaLeaf, betaLeaf], + }, + }; + + const lineByName = (profile: Profile) => { + const m = new Map(); + for (const location of profile.location!) { + const line = location.line![0]; + const fn = getAndVerifyPresence( + profile.function!, + line.functionId as number, + ); + m.set( + profile.stringTable.strings[fn.name as number] as string, + line.line, + ); + } + return m; + }; + + // Default: column dropped, plain generated line 1 retained. + const dropped = lineByName(serializeTimeProfile(bundleProfile, 1000)); + assert.strictEqual(dropped.get('alpha'), 1); + assert.strictEqual(dropped.get('beta'), 1); + + // 'pack': column in the high 32 bits, generated line 1 in the low bits. + const packed = lineByName( + serializeTimeProfile( + bundleProfile, + 1000, + undefined, + false, + undefined, + [], + 'pack', + ), + ); + assert.strictEqual(BigInt(packed.get('alpha')!), (15665n << 32n) | 1n); + assert.strictEqual(BigInt(packed.get('beta')!), (14349n << 32n) | 1n); + }); + it('should omit non-jS threads CPU time when profile has no CPU time', () => { const timeProfile: TimeProfile = { startTime: 0, @@ -198,6 +277,33 @@ describe('profile-serializer', () => { const heapProfileOut = serializeHeapProfile(v8HeapProfile, 0, 512 * 1024); assert.deepEqual(heapProfileOut, heapProfile); }); + it('should pack the column into the emitted line when columnNumbers is "pack"', () => { + // v8HeapProfile frames all use columnNumber 5; with 'pack' each emitted + // line must carry that column in its high 32 bits. + const packed = serializeHeapProfile( + v8HeapProfile, + 0, + 512 * 1024, + undefined, + undefined, + undefined, + false, + 'pack', + ); + for (const location of packed.location!) { + const line = location.line![0]; + assert.strictEqual( + BigInt(line.line) >> 32n, + 5n, + 'column packed into high bits', + ); + } + // Default still drops the column (plain line, no high bits). + const dropped = serializeHeapProfile(v8HeapProfile, 0, 512 * 1024); + for (const location of dropped.location!) { + assert.strictEqual(BigInt(location.line![0].line) >> 32n, 0n); + } + }); it('should produce expected profile when there is anonymous function', () => { const heapProfileOut = serializeHeapProfile( v8AnonymousFunctionHeapProfile, From 0a6fd9f170727065074339efe14980379f2a4a5f Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 23 Jul 2026 09:48:34 +0200 Subject: [PATCH 6/8] fix: scope column packing to frames with a missing source map (#378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: scope column packing to frames with a missing source map columnNumbers: 'pack' previously packed the column into the line for every frame. That leaked the packed value into profiles that skip server-side unminification (e.g. Node.js profiles whose maps resolved locally, or that never had missing maps), which in turn forced the backend to strip it back out of every parse-time structure. Pack only frames whose source map was declared but missing (loc.missingMapFile) — exactly the frames that set dd:has-missing-map-files and are sent for server-side unminification. Locally-resolved and no-map frames keep their plain line, so profiles that skip unminification never carry packed line numbers and the backend needs no stripping. This mirrors how the Chrome path already works. --- ts/src/profile-serializer.ts | 12 ++- ts/test/test-profile-serializer.ts | 120 +++++++++++++++-------------- 2 files changed, 70 insertions(+), 62 deletions(-) diff --git a/ts/src/profile-serializer.ts b/ts/src/profile-serializer.ts index 3435dd1b..5f74d7a3 100644 --- a/ts/src/profile-serializer.ts +++ b/ts/src/profile-serializer.ts @@ -157,6 +157,7 @@ function serialize( const functions: Function[] = []; const functionIdMap = new Map(); const locationIdMap = new Map(); + const packColumns = columnNumbers === 'pack'; let hasMissingMapFiles = false; @@ -229,12 +230,15 @@ 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; return new Line({ functionId: getFunction(loc, scriptId).id, - line: - columnNumbers === 'pack' - ? packLineAndColumn(loc.line, loc.column) - : loc.line, + line: packColumn ? packLineAndColumn(loc.line, loc.column) : loc.line, }); } diff --git a/ts/test/test-profile-serializer.ts b/ts/test/test-profile-serializer.ts index d4c7b9ea..c2c0b085 100644 --- a/ts/test/test-profile-serializer.ts +++ b/ts/test/test-profile-serializer.ts @@ -82,15 +82,13 @@ describe('profile-serializer', () => { assert.deepEqual(timeProfileOut, timeProfile); }); - it('should drop the column by default and pack it when columnNumbers is "pack"', () => { - // Regression test for SCP-1293: a bundled/minified module (e.g. a - // Next.js server chunk) emits every function onto generated line 1, so - // the column is the only discriminator between functions. The pprof Line - // message carries no dedicated column, so with columnNumbers='pack' the - // column is packed into the high 32 bits (column<<32 | line) — the - // encoding the Datadog deobfuscation backend decodes. The default 'drop' - // must leave the plain line untouched for backwards compatibility. - const alphaLeaf = { + it('does not pack the column for frames without a missing source map, even when columnNumbers is "pack"', () => { + // Packing is scoped to frames whose source map was declared but missing + // (dd:has-missing-map-files) — the ones bound for server-side + // unminification. A frame that never went through a missing map (here: + // no source mapper at all) keeps its plain line even under 'pack', so + // profiles that skip server-side unminification never carry packed lines. + const leaf = { name: 'alpha', scriptName: '/opt/app/.next/server/chunks/58844.js', scriptId: 1, @@ -99,15 +97,6 @@ describe('profile-serializer', () => { hitCount: 3, children: [], }; - const betaLeaf = { - name: 'beta', - scriptName: '/opt/app/.next/server/chunks/58844.js', - scriptId: 1, - lineNumber: 1, - columnNumber: 14349, - hitCount: 2, - children: [], - }; const bundleProfile: TimeProfile = { startTime: 0, endTime: 10 * 1000 * 1000, @@ -120,45 +109,32 @@ describe('profile-serializer', () => { lineNumber: 0, columnNumber: 0, hitCount: 0, - children: [alphaLeaf, betaLeaf], + children: [leaf], }, }; - const lineByName = (profile: Profile) => { - const m = new Map(); - for (const location of profile.location!) { - const line = location.line![0]; - const fn = getAndVerifyPresence( - profile.function!, - line.functionId as number, - ); - m.set( - profile.stringTable.strings[fn.name as number] as string, - line.line, - ); - } - return m; + const lineOf = (profile: Profile) => { + const location = profile.location![0]; + return BigInt(location.line![0].line); }; - // Default: column dropped, plain generated line 1 retained. - const dropped = lineByName(serializeTimeProfile(bundleProfile, 1000)); - assert.strictEqual(dropped.get('alpha'), 1); - assert.strictEqual(dropped.get('beta'), 1); - - // 'pack': column in the high 32 bits, generated line 1 in the low bits. - const packed = lineByName( - serializeTimeProfile( - bundleProfile, - 1000, - undefined, - false, - undefined, - [], - 'pack', + // Neither 'drop' (default) nor 'pack' packs the column when there is no + // missing source map — there is no server-side unminification to feed. + assert.strictEqual(lineOf(serializeTimeProfile(bundleProfile, 1000)), 1n); + assert.strictEqual( + lineOf( + serializeTimeProfile( + bundleProfile, + 1000, + undefined, + false, + undefined, + [], + 'pack', + ), ), + 1n, ); - assert.strictEqual(BigInt(packed.get('alpha')!), (15665n << 32n) | 1n); - assert.strictEqual(BigInt(packed.get('beta')!), (14349n << 32n) | 1n); }); it('should omit non-jS threads CPU time when profile has no CPU time', () => { @@ -277,9 +253,10 @@ describe('profile-serializer', () => { const heapProfileOut = serializeHeapProfile(v8HeapProfile, 0, 512 * 1024); assert.deepEqual(heapProfileOut, heapProfile); }); - it('should pack the column into the emitted line when columnNumbers is "pack"', () => { - // v8HeapProfile frames all use columnNumber 5; with 'pack' each emitted - // line must carry that column in its high 32 bits. + it('does not pack the column without a missing source map, even when columnNumbers is "pack"', () => { + // Packing is scoped to frames with a missing source map (bound for + // server-side unminification). v8HeapProfile has no source mapper, so no + // frame is missing a map and lines stay plain under both 'drop' and 'pack'. const packed = serializeHeapProfile( v8HeapProfile, 0, @@ -291,14 +268,12 @@ describe('profile-serializer', () => { 'pack', ); for (const location of packed.location!) { - const line = location.line![0]; assert.strictEqual( - BigInt(line.line) >> 32n, - 5n, - 'column packed into high bits', + BigInt(location.line![0].line) >> 32n, + 0n, + 'column must not be packed without a missing source map', ); } - // Default still drops the column (plain line, no high bits). const dropped = serializeHeapProfile(v8HeapProfile, 0, 512 * 1024); for (const location of dropped.location!) { assert.strictEqual(BigInt(location.line![0].line) >> 32n, 0n); @@ -502,6 +477,35 @@ describe('profile-serializer', () => { assertHasMissingMapToken(profile); }); + it('packs the column for a missing-map frame when columnNumbers is "pack"', () => { + // The frame's map is declared but missing, so it is bound for server-side + // unminification (same condition as the missing-map token). Its generated + // column (1) is packed into the high 32 bits of the emitted line. + const profile = serializeTimeProfile( + makeSingleNodeTimeProfile(missingJsPath), + 1000, + sourceMapper, + false, + undefined, + [], + 'pack', + ); + assertHasMissingMapToken(profile); + assert.strictEqual( + BigInt(profile.location![0].line![0].line), + (1n << 32n) | 1n, + ); + }); + + it('leaves a missing-map frame line plain under the default "drop"', () => { + const profile = serializeTimeProfile( + makeSingleNodeTimeProfile(missingJsPath), + 1000, + sourceMapper, + ); + assert.strictEqual(BigInt(profile.location![0].line![0].line), 1n); + }); + it('serializeHeapProfile emits missing-map token when a map is declared but absent', () => { // serialize() iterates root.children, so the node with allocations must // be a child of the root passed to serializeHeapProfile. From 2bae4a96b9d8eff0f4b6fca619ab5d73b7d07e87 Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Wed, 22 Jul 2026 15:33:45 +0200 Subject: [PATCH 7/8] test(time-profiler): stabilize flaky "should update state" (#362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test was intermittently failing across platforms (e.g. centos-test-26, darwin-arm builds) with either "No context found" or "Unexpected context". Two compounding causes: 1. It established the sampling context with a bare setContext(). Under useCPED that only takes effect when the current continuation already has an AsyncContextFrame; otherwise it silently no-ops. Whether one existed depended on what ran before, so the context was often never stored and samples came back with an undefined context. 2. It stopped after a single sample. The first sample is taken at profiler start (before any context is set) and is skipped during context association, so one sample left nothing reliable to attach the context to. Establish the context the way production code does — runWithContext when useCPED is enabled, setContext otherwise — and wait for several samples before stopping. Reproduced on Node 26: the test failed 30/30 runs in isolation before, and passes 30/30 in isolation and 25/25 in the full file after. --- ts/test/test-time-profiler.ts | 81 +++++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/ts/test/test-time-profiler.ts b/ts/test/test-time-profiler.ts index 54c5b856..ede45f7a 100644 --- a/ts/test/test-time-profiler.ts +++ b/ts/test/test-time-profiler.ts @@ -69,42 +69,69 @@ describe('Time Profiler', () => { useCPED, }); const initialContext: {[key: string]: string} = {}; - time.setContext(initialContext); const kSampleCount = time.constants.kSampleCount; const state = time.getState(); assert.equal(state[kSampleCount], 0, 'Initial state should be 0'); - const deadline = Date.now() + 1000; - while (state[kSampleCount] === 0) { - if (Date.now() > deadline) { - assert.fail('State did not change'); - } - } - assert(state[kSampleCount] >= 1, 'Unexpected number of samples'); let checked = false; - initialContext['aaa'] = 'bbb'; - let endTime = 0n; - time.stop(false, ({node, context}: GenerateTimeLabelsArgs) => { - if (node.name === time.constants.NON_JS_THREADS_FUNCTION_NAME) { - return {}; - } - assert.ok(context !== null, 'Context should not be null'); - if (!endTime) { - endTime = BigInt(Date.now()) * 1000n; + // Collect several samples while the context is active, then verify the + // context was associated with them. Two things make this robust against + // the flakiness this test used to exhibit: + // - We establish the context the way production code does: via + // runWithContext when useCPED is enabled, and setContext otherwise. + // A bare setContext under useCPED only takes effect when the current + // continuation already has an AsyncContextFrame, so on its own it + // silently no-ops — which made the result depend on test ordering. + // - We wait for several samples rather than a single one. The first + // sample is taken at profiler start (before any context is set) and + // is skipped during context association, so relying on just one + // sample left nothing reliable to associate the context with. + const minSamples = 5; + function sampleAndVerify() { + const deadline = Date.now() + 5_000; + while (state[kSampleCount] < minSamples) { + if (Date.now() > deadline) { + assert.fail( + `Only ${state[kSampleCount]} sample(s) collected, expected ${minSamples}`, + ); + } } - assert.deepEqual( - context!.context, - initialContext, - 'Unexpected context', - ); + // Mutate the context object after the samples were taken: the profiler + // stores a live reference to it, so the change must be visible below. + initialContext['aaa'] = 'bbb'; + + let endTime = 0n; + time.stop(false, ({node, context}: GenerateTimeLabelsArgs) => { + if (node.name === time.constants.NON_JS_THREADS_FUNCTION_NAME) { + return {}; + } + assert.ok(context !== null, 'Context should not be null'); + if (!endTime) { + endTime = BigInt(Date.now()) * 1000n; + } + + assert.deepEqual( + context!.context, + initialContext, + 'Unexpected context', + ); + + assert.ok(context!.timestamp >= startTime); + assert.ok(context!.timestamp <= endTime); + checked = true; + return {...context!.context}; + }); + } + + if (useCPED) { + time.runWithContext(initialContext, sampleAndVerify); + } else { + time.setContext(initialContext); + sampleAndVerify(); + } - assert.ok(context!.timestamp >= startTime); - assert.ok(context!.timestamp <= endTime); - checked = true; - return {...context!.context}; - }); assert(checked, 'No context found'); }); From 8cac0394d14f749c1a36d814a3f7e57227cc726e Mon Sep 17 00:00:00 2001 From: Attila Szegedi Date: Thu, 23 Jul 2026 09:52:13 +0200 Subject: [PATCH 8/8] v5.17.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 67f5418f..3f9160cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@datadog/pprof", - "version": "5.16.1", + "version": "5.17.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@datadog/pprof", - "version": "5.16.1", + "version": "5.17.0", "license": "Apache-2.0", "dependencies": { "node-gyp-build": "^4.8.4", diff --git a/package.json b/package.json index 2277aca8..bcea3076 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@datadog/pprof", - "version": "5.16.1", + "version": "5.17.0", "description": "pprof support for Node.js", "repository": { "type": "git",