Skip to content

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

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

fix(core): mint the fallback external trace id per run#4526
NERLOE wants to merge 1 commit 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

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>
@changeset-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 75f95c9

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

Hi @NERLOE, thanks for your interest in contributing!

This project requires that pull request authors are vouched, and you are not in the list of vouched users.

This PR will be closed automatically. See https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md for more 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: 3a1f0046-0b97-4462-8c23-22587e5ad91f

📥 Commits

Reviewing files that changed from the base of the PR and between 04f9c4e and 75f95c9.

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

Walkthrough

Fallback external trace IDs now generate once per run instead of once per TracingSDK instance. Span and log exporters resolve the current run’s fallback ID and pass it into transformation. Empty external trace IDs keep external exporting disabled. Tests cover regeneration between runs, reuse within a run, and disabled export behavior.

✨ 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 3 potential issues.

Open in Devin Review

Comment on lines +527 to +535
private fallback: FallbackExternalTraceId;

constructor(
private underlyingExporter: LogRecordExporter,
private externalTraceId: string
) {}
externalTraceId: string,
traceIdGenerator?: Pick<RandomIdGenerator, "generateTraceId">
) {
this.fallback = new FallbackExternalTraceId(externalTraceId, traceIdGenerator);
}

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.

🔴 Logs and traces sent to external observability tools stop lining up for runs without incoming trace context

Each exporter builds its own private generator of the substitute trace id (new FallbackExternalTraceId(...) at packages/core/src/v3/otel/tracingSDK.ts:534) instead of sharing one, so from the second run onwards on a reused process the logs and the spans of the same run are stamped with different, randomly generated ids.

Impact: In the customer's own observability backend, a run's logs are no longer attached to that run's trace, so they appear orphaned and undiscoverable.

Why the two wrappers diverge after the first run

Before this change, TracingSDK generated one externalTraceId string (packages/core/src/v3/otel/tracingSDK.ts:165) and passed the same string to every ExternalSpanExporterWrapper (packages/core/src/v3/otel/tracingSDK.ts:170,182) and every ExternalLogRecordExporterWrapper (packages/core/src/v3/otel/tracingSDK.ts:234,249). Spans and logs therefore always agreed on the fallback trace id.

Now each wrapper constructs its own FallbackExternalTraceId holding independent state (packages/core/src/v3/otel/tracingSDK.ts:444 and :534). On the first run all instances still return the shared seed, but as soon as traceContext.getTraceContext() identity changes (a new run), each instance independently calls traceIdGenerator.generateTraceId() (packages/core/src/v3/otel/tracingSDK.ts:429), producing a different random id per wrapper. The same divergence occurs when a user configures more than one entry in telemetry.exporters.

A shared FallbackExternalTraceId instance created once in the TracingSDK constructor and passed to all wrappers would keep the per-run remint while preserving cross-signal correlation.

Prompt for agents
In packages/core/src/v3/otel/tracingSDK.ts, the new FallbackExternalTraceId is instantiated separately inside ExternalSpanExporterWrapper and ExternalLogRecordExporterWrapper. Previously all wrappers received one identical externalTraceId string generated once in the TracingSDK constructor, which guaranteed that spans and logs of a run without external trace context shared the same external trace id. With per-wrapper instances, the first remint (second run on a warm process) makes each wrapper generate its own random trace id, so a run's logs and spans no longer correlate in the external backend; multiple configured span exporters diverge too. Consider constructing a single FallbackExternalTraceId in the TracingSDK constructor (seeded with the generated id) and injecting that shared instance into every ExternalSpanExporterWrapper and ExternalLogRecordExporterWrapper, keeping the existing per-run remint semantics and the tests' ability to inject a fake id generator.
Open in Devin Review

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

"@trigger.dev/core": patch
---

Mint the fallback external trace id per run rather than once per `TracingSDK`. Runs that carry no external trace context fall back to a generated trace id, and with `experimental_processKeepAlive` the `TracingSDK` outlives the run — so every run on a warm process was exported to the external OTLP endpoint under one shared trace id, merging unrelated runs into a single trace.

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.

🟡 Release note text describes internals instead of user-visible behaviour

The release note added for this change leads with implementation detail and names an internal component ("Mint the fallback external trace id per run rather than once per TracingSDK" in .changeset/external-trace-id-per-run.md:5), which the repository guidelines forbid for user-facing notes.

Impact: Users reading the release notes see internal jargon rather than a plain description of what changed for them.

Rule reference

AGENTS.md, section "Changesets and Server Changes": "Write the description for users, not maintainers. ... Lead with what changed for the user - one plain sentence describing behavior, not implementation, and never naming internal tools or infra."

Suggested change
Mint the fallback external trace id per run rather than once per `TracingSDK`. Runs that carry no external trace context fall back to a generated trace id, and with `experimental_processKeepAlive` the `TracingSDK` outlives the runso every run on a warm process was exported to the external OTLP endpoint under one shared trace id, merging unrelated runs into a single trace.
Runs that don't start from an incoming trace are no longer merged together when they run on the same warm worker processeach run now gets its own trace in your own observability tool.
Open in Devin Review

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

Comment on lines +425 to +430
const currentTraceContext = traceContext.getTraceContext();

if (currentTraceContext !== this.seenTraceContext) {
this.seenTraceContext = currentTraceContext;
this.traceId = this.traceIdGenerator.generateTraceId();
}

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.

🔍 Run-boundary detection degrades to "every export" when no trace context manager is registered

The remint trigger is reference-identity of traceContext.getTraceContext(). When no manager has been registered, the API falls back to NoopTraceContextManager, whose getTraceContext() returns a freshly allocated {} on every call (packages/core/src/v3/traceContext/index.ts), so currentTraceContext !== this.seenTraceContext is always true and a brand-new fallback trace id is minted on every single export batch — shattering one logical trace into many.

Today this is not reachable in production: the only TracingSDK instances that receive exporters/logExporters are in packages/cli-v3/src/entryPoints/managed-run-worker.ts:215 and dev-run-worker.ts:243, both of which register StandardTraceContextManager beforehand; the index workers (managed-index-worker.ts:95, dev-index-worker.ts:101) construct TracingSDK without external exporters, so no wrapper is created. Still, the invariant "a manager is always registered" is implicit and unguarded — a cheap defence would be to treat an empty/noop context as "no change".

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