Skip to content

fix(time-profiler): serialize before clearing the source mapper in stop()#342

Open
mcollina wants to merge 2 commits into
DataDog:v5.xfrom
mcollina:fix/sourcemap-stop
Open

fix(time-profiler): serialize before clearing the source mapper in stop()#342
mcollina wants to merge 2 commits into
DataDog:v5.xfrom
mcollina:fix/sourcemap-stop

Conversation

@mcollina
Copy link
Copy Markdown

Summary

The legacy time-profiler stop() serializes the profile after tearing down the profiler state, so source maps are silently dropped. CPU profiles taken via start() + stop() with a sourceMapper no longer map transpiled frames (e.g. TypeScript) back to their original sources — they point at the generated .js files instead. This is a regression introduced between 5.14.1 and 5.14.3/5.14.4.

Root cause

In ts/src/time-profiler.ts, stop() runs handleStopNoRestart() (which sets gSourceMapper = undefined and disposes the profiler) before calling serializeTimeProfile(profile, gIntervalMicros, gSourceMapper, …):

const profile = gProfiler.stop(restart);
if (restart) { handleStopRestart(); } else { handleStopNoRestart(); } // clears gSourceMapper
const serializedProfile = serializeTimeProfile(profile, gIntervalMicros, gSourceMapper, ); // undefined!

Because gSourceMapper is undefined at serialization time, serializeTimeProfile never calls sourceMapper.mappingInfo(...), and frames keep their generated-file locations.

stopV2() is unaffected: it serializes inside the stopAndCollect callback, which runs while gSourceMapper is still set. In 5.14.1, stop() itself used the stopAndCollect path, so the mapper was still set at serialization time — hence this is a regression.

Fix

Move serialization ahead of the teardown so the source mapper is still set, matching stopV2() (and the current main branch, which already serializes before clearing). The profile object returned by the native stop() does not depend on the profiler still being alive, so this reordering is safe.

Reproduction

With 5.14.4:

const { time, SourceMapper } = require('@datadog/pprof')
const mapper = await SourceMapper.create([appDir])     // appDir contains dist/foo.js + dist/foo.js.map (-> foo.ts)
time.start({ sourceMapper: mapper, lineNumbers: true, intervalMicros: 1000 })
// ... run transpiled code ...
const profile = time.stop()
// profile functions reference dist/foo.js  ❌  (5.14.1: foo.ts ✅)

sourceMapper.mappingInfo is called 0 times on 5.14.4 vs 26 times on 5.14.1 for the same workload. Using time.stopV2() instead of time.stop() produces the correctly mapped profile, confirming the diagnosis.

Test

Added a deterministic regression test in test-time-profiler.ts (under profile (w/ stubs)): it stubs serializeTimeProfile and asserts stop() passes the source mapper through. It fails on the current code (undefined !== {}) and passes with this fix.

Notes

Issues are disabled on this repository, so I'm opening this as a PR with the fix. Happy to adjust the approach or test as preferred.

…op()

The legacy time profiler stop() called handleStopRestart()/
handleStopNoRestart() before serializing the profile. handleStopNoRestart()
clears gSourceMapper (and disposes the profiler), so serialization ran with
an undefined source mapper: transpiled frames (e.g. TypeScript) were left
pointing at the generated .js files instead of being mapped back to their
original sources.

Move serialization ahead of the teardown so the source mapper is still set,
matching stopV2(), which serializes inside the stopAndCollect callback while
gSourceMapper is still defined. This restores the behaviour of 5.14.1, whose
stop() serialized via stopAndCollect before teardown.

Add a regression test asserting stop() passes the source mapper to
serializeTimeProfile.

Assisted-by: Claude Code:claude-opus-4-8
Signed-off-by: Matteo Collina <hello@matteocollina.com>
Copy link
Copy Markdown

@szegedi szegedi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for finding this!

FWIW, we're switching back to "legacy" stop because with stopV2 we had a larger overlap between two profiling sessions being active which triggered crashes due to a race condition in V8 (see my report and also it was fixed since, but since it's been present for forever, we can't use stopV2 until all non-EOL Node.js versions have the fix.)

Anyhow, I have a nit about this – in the restart case, I still want the profiler to perform the restart early; aside from that, looks good!

Comment thread ts/src/time-profiler.ts

const profile = gProfiler.stop(restart);
if (restart) {
handleStopRestart();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We definitely want to – at least for the restart case – to stop/start the profiler before we serialize, as then the serialization work itself is captured in the next profile; we like to be honest like that and let the users observe its cost :-) Arguably, for the plain stop case it doesn't matter. I guess maybe we'll need to blow up this nice encapsulation of handleStop[No]Restart() here and have an early stop-start for the restart case (which should not dispose of source maps, right?) and a late stop for for no-restart case.

Per review feedback, the profiler restart should still happen before
serialization so the cost of serialization is captured in the next profile.
Only handleStopNoRestart() clears gSourceMapper, so only that path needs to
run after serialization; handleStopRestart() stays early and does not touch
the source mapper.

Extend the regression test to also assert the source mapper is preserved on
the restart (stop(true)) path.

Assisted-by: Claude Code:claude-opus-4-8
Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina
Copy link
Copy Markdown
Author

mcollina commented May 29, 2026

Should be fixed, lmk

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants