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/package-lock.json b/package-lock.json index 92895625..3f9160cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,35 +1,35 @@ { "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", "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.0", + "@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", "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", "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" @@ -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": { @@ -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": { @@ -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": { @@ -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 6d60302a..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", @@ -39,25 +39,25 @@ "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.0", + "@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", "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", "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" 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/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/src/profile-serializer.ts b/ts/src/profile-serializer.ts index d3378c29..5f74d7a3 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,12 +150,14 @@ function serialize( stringTable: StringTable, ignoreSamplesPath?: string, sourceMapper?: SourceMapper, + columnNumbers: ColumnNumbers = DEFAULT_COLUMN_NUMBERS, ) { const samples: Sample[] = []; const locations: Location[] = []; const functions: Function[] = []; const functionIdMap = new Map(); const locationIdMap = new Map(); + const packColumns = columnNumbers === 'pack'; let hasMissingMapFiles = false; @@ -185,9 +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: loc.line, + line: packColumn ? packLineAndColumn(loc.line, loc.column) : loc.line, }); } @@ -386,6 +437,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 +561,7 @@ export function serializeTimeProfile( stringTable, undefined, sourceMapper, + columnNumbers, ); return new Profile(profile); @@ -556,6 +609,7 @@ export function serializeHeapProfile( sourceMapper?: SourceMapper, generateLabels?: GenerateAllocationLabelsFunction, allocations = false, + columnNumbers: ColumnNumbers = DEFAULT_COLUMN_NUMBERS, ): Profile { const appendHeapEntryToSamples: AppendEntryToSamples< AllocationProfileNode | AllocationProfileNodeWithStats @@ -642,6 +696,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-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', diff --git a/ts/test/test-profile-serializer.ts b/ts/test/test-profile-serializer.ts index 0e40e99b..c2c0b085 100644 --- a/ts/test/test-profile-serializer.ts +++ b/ts/test/test-profile-serializer.ts @@ -82,6 +82,61 @@ describe('profile-serializer', () => { assert.deepEqual(timeProfileOut, timeProfile); }); + 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, + lineNumber: 1, + columnNumber: 15665, + hitCount: 3, + 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: [leaf], + }, + }; + + const lineOf = (profile: Profile) => { + const location = profile.location![0]; + return BigInt(location.line![0].line); + }; + + // 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, + ); + }); + it('should omit non-jS threads CPU time when profile has no CPU time', () => { const timeProfile: TimeProfile = { startTime: 0, @@ -198,6 +253,32 @@ describe('profile-serializer', () => { const heapProfileOut = serializeHeapProfile(v8HeapProfile, 0, 512 * 1024); assert.deepEqual(heapProfileOut, heapProfile); }); + 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, + 512 * 1024, + undefined, + undefined, + undefined, + false, + 'pack', + ); + for (const location of packed.location!) { + assert.strictEqual( + BigInt(location.line![0].line) >> 32n, + 0n, + 'column must not be packed without a missing source map', + ); + } + 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, @@ -396,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. 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', + ); + }); +}); 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'); });