Skip to content

Commit 81cf2a5

Browse files
NERLOEclaude
andcommitted
fix(core): share one fallback trace id across span and log exporters
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>
1 parent 2bc20c8 commit 81cf2a5

3 files changed

Lines changed: 96 additions & 32 deletions

File tree

.changeset/external-trace-id-per-run.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@trigger.dev/core": patch
33
---
44

5-
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.
5+
Runs that don't continue an incoming trace are no longer merged into one trace when they execute on the same warm worker process. Each run now appears as its own trace in your external observability tool, so per-run cost and latency attribution works again.

packages/core/src/v3/otel/tracingSDK.ts

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,8 @@ export class TracingSDK {
162162
)
163163
);
164164

165-
const externalTraceId = idGenerator.generateTraceId();
165+
// Shared by every wrapper below so a run's spans and logs agree on the id.
166+
const externalTraceId = new FallbackExternalTraceId(idGenerator.generateTraceId());
166167

167168
for (const exporter of config.exporters ?? []) {
168169
spanProcessors.push(
@@ -393,6 +394,19 @@ function setLogLevel(level: TracingDiagnosticLogLevel) {
393394
diag.setLogger(new DiagConsoleLogger(), diagLogLevel);
394395
}
395396

397+
/**
398+
* Identity of the current run's trace context, or undefined when no run is
399+
* active.
400+
*
401+
* An empty context is not a run: the noop manager returns a fresh `{}` on every
402+
* call, so treating it as a run would mint a new id on every export.
403+
*/
404+
function currentRunTraceContext(): object | undefined {
405+
const current = traceContext.getTraceContext();
406+
407+
return current && Object.keys(current).length > 0 ? current : undefined;
408+
}
409+
396410
/**
397411
* The external trace id used by runs that carry no external trace context,
398412
* minted once per run.
@@ -402,17 +416,20 @@ function setLogLevel(level: TracingDiagnosticLogLevel) {
402416
* — outlive the run, so an id captured at construction merges every run on the
403417
* process into one trace. The manager's trace context object is reassigned per
404418
* run, which makes its identity the run boundary.
419+
*
420+
* One instance is shared by every wrapper, so a run's spans and logs still
421+
* agree on the id after a remint.
405422
*/
406-
class FallbackExternalTraceId {
423+
export class FallbackExternalTraceId {
407424
private traceId: string;
408-
private seenTraceContext: unknown;
425+
private seenTraceContext: object | undefined;
409426

410427
constructor(
411428
private seed: string,
412429
private traceIdGenerator: Pick<RandomIdGenerator, "generateTraceId"> = idGenerator
413430
) {
414431
this.traceId = seed;
415-
this.seenTraceContext = traceContext.getTraceContext();
432+
this.seenTraceContext = currentRunTraceContext();
416433
}
417434

418435
get(): string {
@@ -422,10 +439,10 @@ class FallbackExternalTraceId {
422439
return this.seed;
423440
}
424441

425-
const currentTraceContext = traceContext.getTraceContext();
442+
const current = currentRunTraceContext();
426443

427-
if (currentTraceContext !== this.seenTraceContext) {
428-
this.seenTraceContext = currentTraceContext;
444+
if (current && current !== this.seenTraceContext) {
445+
this.seenTraceContext = current;
429446
this.traceId = this.traceIdGenerator.generateTraceId();
430447
}
431448

@@ -434,15 +451,10 @@ class FallbackExternalTraceId {
434451
}
435452

436453
export class ExternalSpanExporterWrapper {
437-
private fallback: FallbackExternalTraceId;
438-
439454
constructor(
440455
private underlyingExporter: SpanExporter,
441-
externalTraceId: string,
442-
traceIdGenerator?: Pick<RandomIdGenerator, "generateTraceId">
443-
) {
444-
this.fallback = new FallbackExternalTraceId(externalTraceId, traceIdGenerator);
445-
}
456+
private fallback: FallbackExternalTraceId
457+
) {}
446458

447459
private transformSpan(span: ReadableSpan): ReadableSpan | undefined {
448460
// Read external context live, so per-run reassignment of
@@ -463,9 +475,7 @@ export class ExternalSpanExporterWrapper {
463475
return;
464476
}
465477

466-
const externalTraceId = externalTraceContext
467-
? externalTraceContext.traceId
468-
: fallbackTraceId;
478+
const externalTraceId = externalTraceContext ? externalTraceContext.traceId : fallbackTraceId;
469479

470480
const isAttemptSpan = span.attributes[SemanticInternalAttributes.SPAN_ATTEMPT];
471481

@@ -524,15 +534,10 @@ export class ExternalSpanExporterWrapper {
524534
}
525535

526536
class ExternalLogRecordExporterWrapper {
527-
private fallback: FallbackExternalTraceId;
528-
529537
constructor(
530538
private underlyingExporter: LogRecordExporter,
531-
externalTraceId: string,
532-
traceIdGenerator?: Pick<RandomIdGenerator, "generateTraceId">
533-
) {
534-
this.fallback = new FallbackExternalTraceId(externalTraceId, traceIdGenerator);
535-
}
539+
private fallback: FallbackExternalTraceId
540+
) {}
536541

537542
export(logs: any[], resultCallback: (result: any) => void): void {
538543
const externalTraceContext = traceContext.getExternalTraceContext();

packages/core/test/externalSpanExporterWrapper.test.ts

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { SpanKind, SpanStatusCode, TraceFlags } from "@opentelemetry/api";
22
import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-node";
33
import { beforeEach, describe, expect, it } from "vitest";
4-
import { ExternalSpanExporterWrapper } from "../src/v3/otel/tracingSDK.js";
4+
import { ExternalSpanExporterWrapper, FallbackExternalTraceId } from "../src/v3/otel/tracingSDK.js";
55
import { SemanticInternalAttributes } from "../src/v3/semanticInternalAttributes.js";
66
import { traceContext } from "../src/v3/trace-context-api.js";
77
import { StandardTraceContextManager } from "../src/v3/traceContext/manager.js";
@@ -66,7 +66,10 @@ describe("ExternalSpanExporterWrapper warm-start regression", () => {
6666

6767
manager.traceContext = { external: { traceparent: TRACEPARENT_RUN_A } };
6868

69-
const wrapper = new ExternalSpanExporterWrapper(exporter, "ffffffffffffffffffffffffffffffff");
69+
const wrapper = new ExternalSpanExporterWrapper(
70+
exporter,
71+
new FallbackExternalTraceId("ffffffffffffffffffffffffffffffff")
72+
);
7073

7174
manager.traceContext = { external: { traceparent: TRACEPARENT_RUN_B } };
7275

@@ -98,8 +101,7 @@ describe("ExternalSpanExporterWrapper warm-start regression", () => {
98101

99102
const wrapper = new ExternalSpanExporterWrapper(
100103
exporter,
101-
"ffffffffffffffffffffffffffffffff",
102-
idGenerator
104+
new FallbackExternalTraceId("ffffffffffffffffffffffffffffffff", idGenerator)
103105
);
104106

105107
wrapper.export([createAttemptSpan()], () => {});
@@ -130,8 +132,7 @@ describe("ExternalSpanExporterWrapper warm-start regression", () => {
130132

131133
const wrapper = new ExternalSpanExporterWrapper(
132134
exporter,
133-
"ffffffffffffffffffffffffffffffff",
134-
idGenerator
135+
new FallbackExternalTraceId("ffffffffffffffffffffffffffffffff", idGenerator)
135136
);
136137

137138
wrapper.export([createAttemptSpan()], () => {});
@@ -150,12 +151,70 @@ describe("ExternalSpanExporterWrapper warm-start regression", () => {
150151

151152
manager.traceContext = { traceparent: TRACEPARENT_RUN_A };
152153

153-
const wrapper = new ExternalSpanExporterWrapper(exporter, "", idGenerator);
154+
const wrapper = new ExternalSpanExporterWrapper(
155+
exporter,
156+
new FallbackExternalTraceId("", idGenerator)
157+
);
154158

155159
wrapper.export([createAttemptSpan()], () => {});
156160

157161
// Minting an id here would switch external export on for a deployment that
158162
// never asked for it.
159163
expect(captured[0]).toHaveLength(0);
160164
});
165+
166+
// The TracingSDK shares one FallbackExternalTraceId across every span and log
167+
// wrapper. Giving each wrapper its own would remint them independently, so
168+
// from the second run on, a run's logs would carry a different trace id than
169+
// its spans and stop correlating in the external backend.
170+
it("gives every wrapper sharing one fallback the same id after a remint", () => {
171+
const first = makeCapturingExporter();
172+
const second = makeCapturingExporter();
173+
174+
let generated = 0;
175+
const idGenerator = {
176+
generateTraceId: () => `${++generated}`.padStart(32, "0"),
177+
};
178+
179+
manager.traceContext = { traceparent: TRACEPARENT_RUN_A };
180+
181+
const fallback = new FallbackExternalTraceId("ffffffffffffffffffffffffffffffff", idGenerator);
182+
const spanWrapper = new ExternalSpanExporterWrapper(first.exporter, fallback);
183+
const otherWrapper = new ExternalSpanExporterWrapper(second.exporter, fallback);
184+
185+
manager.traceContext = { traceparent: TRACEPARENT_RUN_B };
186+
187+
spanWrapper.export([createAttemptSpan()], () => {});
188+
otherWrapper.export([createAttemptSpan()], () => {});
189+
190+
expect(second.captured[0]![0]!.spanContext().traceId).toBe(
191+
first.captured[0]![0]!.spanContext().traceId
192+
);
193+
expect(generated).toBe(1);
194+
});
195+
196+
// The noop manager returns a fresh `{}` on every call, so using its identity
197+
// as the run boundary would remint on every export and shatter one run's
198+
// trace into many.
199+
it("holds the id when no trace context manager is registered", () => {
200+
const { exporter, captured } = makeCapturingExporter();
201+
202+
let generated = 0;
203+
const idGenerator = {
204+
generateTraceId: () => `${++generated}`.padStart(32, "0"),
205+
};
206+
207+
traceContext.disable();
208+
209+
const wrapper = new ExternalSpanExporterWrapper(
210+
exporter,
211+
new FallbackExternalTraceId("ffffffffffffffffffffffffffffffff", idGenerator)
212+
);
213+
214+
wrapper.export([createAttemptSpan()], () => {});
215+
wrapper.export([createAttemptSpan()], () => {});
216+
217+
expect(captured[1]![0]!.spanContext().traceId).toBe(captured[0]![0]!.spanContext().traceId);
218+
expect(generated).toBe(0);
219+
});
161220
});

0 commit comments

Comments
 (0)