Skip to content

[FEATURE]: A public span-finish hook (with Span read accessors) for source maps on TypeScript backends #9295

Description

@shanempope

Package Name

dd-trace

Package Version(s)

7.0.0-pre

Describe the feature you'd like

Related to #1255 ("Source maps for node typescript + APM", open since 2021), which asks for this outcome. The request is a public span-finish hook (Option A below) so consumers can transform a span before export without reaching into private internals. An opt-in native remap (Option B) is a narrower alternative if you'd rather dd-trace own the specific source-map case. Both have a working proof-of-concept PR (linked at the very bottom).

The problem. TypeScript backends compiled to dist/*.js show transpiled frames in APM and Error Tracking instead of the original src/*.ts locations, because dd-trace does not symbolicate backend Node stacks server-side. Node's global --enable-source-maps fixes the display but taxes every Error.prepareStackTrace in the process, not just the errors actually reported to Datadog. We tried it and the CPU cost was too high for us in production, because our workload throws and catches enough errors on hot paths that paying source-map resolution on every one of them was not viable. So today the only bounded fix is to remap in-process, on the spans dd-trace is about to export, and there is no public API for that.

What consumers are forced to do today. Subscribe to the internal dd-trace:span:finish diagnostics channel and rewrite the stack in place on the live span before the processor formats it, reaching into private state: the error.stack string tag via span.context()._tags, the raw Error object stored in the error tag (rewriting its .stack), and span._events[].attributes.stacktrace for graphql resolver errors. The public Span interface can write (setTag, addTags) but cannot read tags, the error object, or events, so any consumer that wants to transform what an integration already recorded is pushed into private, version-coupled internals.

Why the channel already works. In opentracing/span.js, finish() calls finishCh.publish(this) synchronously and only then this._processor.process(this), so subscribers get the live, still-mutable span before it is serialized, and setTag has no post-finish guard. span_format.js's extractError reads error.stack off the error object at format time, so mutating that object's .stack in the finish handler lands in the exported meta. The pattern is already first-party: dd-trace's own openfeature/span-enrichment-hook.js and llmobs/index.js both subscribe to dd-trace:span:finish and mutate the span in the handler. The gap is only that reading the error carriers has no public API.

Option A (the request): promote the finish interception to a supported hook

PoC: shanempope#1

Formalize the extension point that already exists: a documented tracer-wide span-finish hook in init options, symmetric with the existing per-plugin hooks.request and hooks.execute, that runs after integrations tag the span and before export (throw-safe, so a hook error never breaks span export), plus stable read accessors on the public Span (getTag(key), getError(), getEvents()) so consumers never touch _tags, _events, or the raw error object. With that, the #1255 remap needs zero private access:

const tracer = require("dd-trace").init({
  hooks: {
    span: (span) => {
      const error = span.getError();
      if (error) {
        error.stack = remapStack(error.stack);
        span.setTag("error", error);
      }
    },
  },
});

Option B (narrower alternative): remap APM error stacks natively, opt-in

PoC: shanempope#2

If you'd rather dd-trace own the specific source-map case rather than expose a general hook: dd-trace already owns source-map resolution in-tree (AppSec/IAST getOriginalPathAndLineFromSourceMap) and already ships an opt-in source-map flag for the profiler (DD_PROFILING_SOURCE_MAP). This wires the same capability into the APM error path in extractError, gated behind an opt-in flag mirroring the profiler's (experimental.sourceMap, env DD_TRACE_SOURCE_MAP, default off). Because it runs at format time on error spans only, it carries none of the global --enable-source-maps prepareStackTrace tax and is bounded to the frames dd-trace is about to export. The disabled path is byte-identical.

const tracer = require("dd-trace").init({
  experimental: { sourceMap: true },
});

Is your feature request related to a problem?

No response

Describe alternatives you've considered

Our current workaround, is to subscribe to the internal dd-trace:span:finish diagnostics channel and mutate the live span in the handler, exactly as described under "What consumers are forced to do today" above. It works, but it depends on private, version-coupled internals (span.context()._tags, the raw Error object in the error tag, and span._events), so it can break on any dd-trace upgrade. That brittleness is why we are asking for a supported API.

Additional context

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions