Skip to content

fix(core): mint the fallback external trace id per run - #4533

Closed
NERLOE wants to merge 3 commits into
triggerdotdev:mainfrom
NERLOE:fix/external-trace-id-per-run
Closed

fix(core): mint the fallback external trace id per run#4533
NERLOE wants to merge 3 commits into
triggerdotdev:mainfrom
NERLOE:fix/external-trace-id-per-run

Conversation

@NERLOE

@NERLOE NERLOE commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Problem

Runs that carry no external trace context (schedules, task-to-task triggers, anything not started from an incoming traceparent) fall back to a generated external trace id. That id is generated once, in the TracingSDK constructor:

https://github.com/triggerdotdev/trigger.dev/blob/main/packages/core/src/v3/otel/tracingSDK.ts#L165

With experimental_processKeepAlive enabled, the TracingSDK outlives the run, so every run that executes on a warm process is exported to the external OTLP endpoint under that same trace id and unrelated runs get merged into one trace on the receiving backend.

This is the same warm-start hazard that c043c4a fixed for the external-context path. That commit made the wrappers read traceContext.getExternalTraceContext() live instead of capturing it at construction, but deliberately left the fallback captured, so the bug survives for exactly the runs that have no external context.

What it looks like in production

We export to a self-hosted Langfuse via telemetry.exporters. Measured over our production traces:

  • 80.3% of traces contain spans from more than one Trigger run
  • worst case: 25 distinct runs collapsed into a single trace

Per-trace cost and latency attribution is meaningless as a result: a trace shows an unrelated mix of workloads, and drilling into one run is impossible.

Disabling experimental_processKeepAlive avoids it, but that is a significant throughput regression and not a real option for us.

Fix

FallbackExternalTraceId holds the generated id and remints it when the run changes. The TracingSDK constructs one instance and passes it to every ExternalSpanExporterWrapper and ExternalLogRecordExporterWrapper, so a run's spans and logs agree on the id after a remint. That matches the old behaviour, where all wrappers received one identical string.

The run boundary comes from the manager rather than being inferred. StandardTraceContextManager.traceContext becomes an accessor pair that advances an epoch whenever the context is replaced, which is exactly what starting a run does, so no call site changes. getTraceContextEpoch() is added to TraceContextManager, and the noop manager reports a constant, so with no manager registered there are no boundaries to react to.

I did first try inferring the boundary from reference-identity of getTraceContext(). It works, but guarding it against the noop manager's freshly allocated {} requires testing the context for emptiness, and since traceContext is z.record(z.unknown()) that silently stops reminting for a run whose context is legitimately empty, merging it back into the previous run's trace. The epoch avoids the heuristic entirely.

One behaviour held deliberately: an empty configured id still means external export is off, so the empty seed short-circuits rather than minting an id and switching the feature on for a deployment that never asked for it.

Tests

packages/core/test/externalSpanExporterWrapper.test.ts gains six cases:

  • mints a new fallback trace id per run when there is no external context
  • mints a new fallback trace id for a run whose trace context is empty
  • keeps one fallback trace id across every export within a run
  • leaves external export off when no external trace id was configured
  • keeps a run's spans and logs on the same id after a remint
  • holds the id when no trace context manager is registered

Mutation-checked. Removing the epoch bump fails three of them, and reinstating the emptiness heuristic fails the empty-context case with the exact symptom it describes. Full packages/core suite passes (674 tests).

The harness needed one fix to make these meaningful. traceContext.setGlobalManager() delegates to registerGlobal, which ignores a second registration, so the beforeEach only ever installed the first test's manager and every later test was mutating an object that was no longer global. Calling traceContext.disable() first makes each test's manager actually take effect.

Happy to split the traceContext epoch into its own commit or PR if you'd rather review it separately, or to take a different approach to the boundary entirely.

🤖 Generated with Claude Code


Supersedes #4526, which the vouching bot auto-closed before I was on the list and which GitHub will no longer let me reopen (its head is stuck at the first commit). Devin's three findings on that PR are addressed here; see the notes on the closed PR and the Fix section above.

NERLOE and others added 3 commits August 7, 2026 16:09
Runs that carry no external trace context (schedules, task-to-task
triggers) fall back to a trace id generated once in the TracingSDK
constructor. With `experimental_processKeepAlive` the TracingSDK outlives
the run, so every run on a warm process was exported to the external OTLP
endpoint under that one id — merging unrelated runs into a single trace.

This is the same warm-start hazard c043c4a fixed for the external
context path, which read the context live but deliberately left the
fallback captured at construction.

Remint the fallback when the trace context manager's context object is
reassigned, which is the run boundary. An empty configured id still means
external export is off and is left alone rather than switched on.

The test harness needed a fix too: `setGlobalManager` delegates to
`registerGlobal`, which ignores a second registration, so every test after
the first was mutating the first test's manager.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Address review feedback on the per-run fallback.

Giving each exporter wrapper its own FallbackExternalTraceId reintroduced
the problem it was meant to fix, one signal down: before, every wrapper
got the same generated string, so a run's spans and logs agreed. With
per-wrapper state each one reminted independently, so from the second run
on a warm process the logs carried a different trace id than the spans and
stopped correlating. Construct one instance in the TracingSDK and pass it
to every span and log wrapper.

Also stop treating an empty trace context as a run boundary. The noop
manager returns a fresh object on every call, so its identity always
differs and would remint on every export batch, shattering one run's trace
into many. Not reachable today (the wrappers are only built where a
StandardTraceContextManager is registered) but the invariant was implicit.

Rewrite the changeset for users per AGENTS.md, and format with oxfmt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Detecting the run boundary by reference-identity of the trace context was
a heuristic, and guarding it against the noop manager's fresh `{}` meant
testing the context for emptiness. That traded an unreachable bug for a
reachable one: `traceContext` is `z.record(z.unknown())`, so a run whose
context is empty would stop reminting and silently merge back into the
previous run's trace.

Replace the inference with a fact. `StandardTraceContextManager.traceContext`
becomes an accessor pair that advances an epoch whenever the context is
replaced, which is exactly what starting a run does, so no call site
changes. The noop manager reports a constant epoch, so with no manager
registered there are no boundaries to react to and nothing churns.

Drops the emptiness heuristic entirely, and covers the case it would have
broken. Also renames `get()` to `forCurrentRun()` and the shared instance
to `fallbackTraceId`, since the old name read as a string, and exports the
log wrapper so a test can prove a run's spans and logs stay on one id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f837ccc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
@trigger.dev/core Patch
@trigger.dev/build Patch
trigger.dev Patch
@trigger.dev/python Patch
@trigger.dev/redis-worker Patch
@trigger.dev/schema-to-json Patch
@trigger.dev/sdk Patch
@internal/cache Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/metrics-pipeline Patch
@trigger.dev/rbac Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@trigger.dev/sso Patch
@internal/testcontainers Patch
@internal/tracing Patch
@internal/tsql Patch
@internal/dashboard-agent Patch
@internal/sdk-compat-tests Patch
@trigger.dev/react-hooks Patch
@trigger.dev/rsc Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Thanks for your contribution! We require all external PRs to be opened in draft status first so you can address CodeRabbit review comments and ensure CI passes before requesting a review. Please re-open this PR as a draft. See CONTRIBUTING.md for details.

@github-actions github-actions Bot closed this Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d58a3998-4e26-4a85-9bf2-985f3a4d5a75

📥 Commits

Reviewing files that changed from the base of the PR and between 98cdf89 and f837ccc.

📒 Files selected for processing (6)
  • .changeset/external-trace-id-per-run.md
  • packages/core/src/v3/otel/tracingSDK.ts
  • packages/core/src/v3/traceContext/api.ts
  • packages/core/src/v3/traceContext/manager.ts
  • packages/core/src/v3/traceContext/types.ts
  • packages/core/test/externalSpanExporterWrapper.test.ts

Walkthrough

The trace context API now exposes a context replacement epoch. FallbackExternalTraceId uses this epoch to remint fallback IDs between runs while preserving empty-seed behavior. Span and log exporter wrappers share the fallback and resolve it during export. Tests cover reminting, stability, disabled export, span/log correlation, and operation without a registered context manager. A patch changeset documents the update.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

Open in Devin Review

Comment on lines +17 to +20
set traceContext(value: Record<string, unknown>) {
this.#traceContext = value;
this.#epoch++;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Epoch bumps per execution message, so retry attempts of one run get different fallback trace ids

The run boundary is defined as "the trace context object was replaced". In the workers, standardTraceContextManager.traceContext = traceContext runs once per EXECUTE_TASK_RUN message (packages/cli-v3/src/entryPoints/managed-run-worker.ts:403, packages/cli-v3/src/entryPoints/dev-run-worker.ts:426), and reset() also assigns (packages/core/src/v3/traceContext/manager.ts:31), so the epoch advances at least twice per execution message. Since each retry attempt of the same run arrives as its own EXECUTE_TASK_RUN, a run that retries on the same warm process will mint a new fallback external trace id per attempt, i.e. the granularity is per-attempt rather than per-run (the changeset text says "Each run now appears as its own trace"). This is consistent with the cold-start behaviour (a fresh process always generated a fresh id), so it is not a regression, but it's worth confirming the intended granularity — the runs whose attempts land on the same warm process will no longer be grouped, and there is currently no signal in the trace context to distinguish "new attempt of the same run" from "new run".

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

1 participant