Skip to content

Commit e840eb0

Browse files
NERLOEclaude
andcommitted
fix(core): give each run its own external fallback trace id
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. Across our production traces, 80.3% contained spans from more than one run, worst case 25. 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. Key the fallback off the internal trace id that every span and log record of a run already carries, rather than off ambient state. Batch processors drain asynchronously, so a run's records are routinely exported after the next run has started; deciding the id at export time from whatever run is current would stamp the earlier run's records with the later run's id. Letting the record decide sidesteps the timing entirely, and makes a run's spans and logs agree without coordinating. The map is bounded, since a warm process serves unboundedly many runs and only the in-flight ones can still have records to export. An empty configured id still means external export is off. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 63176a6 commit e840eb0

3 files changed

Lines changed: 281 additions & 20 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
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: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -162,12 +162,13 @@ 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 fallbackTraceId = new FallbackExternalTraceId(idGenerator.generateTraceId());
166167

167168
for (const exporter of config.exporters ?? []) {
168169
spanProcessors.push(
169170
getEnvVar("TRIGGER_OTEL_BATCH_PROCESSING_ENABLED") === "1"
170-
? new BatchSpanProcessor(new ExternalSpanExporterWrapper(exporter, externalTraceId), {
171+
? new BatchSpanProcessor(new ExternalSpanExporterWrapper(exporter, fallbackTraceId), {
171172
maxExportBatchSize: parseInt(
172173
getEnvVar("TRIGGER_OTEL_SPAN_MAX_EXPORT_BATCH_SIZE") ?? "64"
173174
),
@@ -179,7 +180,7 @@ export class TracingSDK {
179180
),
180181
maxQueueSize: parseInt(getEnvVar("TRIGGER_OTEL_SPAN_MAX_QUEUE_SIZE") ?? "512"),
181182
})
182-
: new SimpleSpanProcessor(new ExternalSpanExporterWrapper(exporter, externalTraceId))
183+
: new SimpleSpanProcessor(new ExternalSpanExporterWrapper(exporter, fallbackTraceId))
183184
);
184185
}
185186

@@ -231,7 +232,7 @@ export class TracingSDK {
231232
logProcessors.push(
232233
getEnvVar("TRIGGER_OTEL_BATCH_PROCESSING_ENABLED") === "1"
233234
? new BatchLogRecordProcessor(
234-
new ExternalLogRecordExporterWrapper(externalLogExporter, externalTraceId),
235+
new ExternalLogRecordExporterWrapper(externalLogExporter, fallbackTraceId),
235236
{
236237
maxExportBatchSize: parseInt(
237238
getEnvVar("TRIGGER_OTEL_LOG_MAX_EXPORT_BATCH_SIZE") ?? "64"
@@ -246,7 +247,7 @@ export class TracingSDK {
246247
}
247248
)
248249
: new SimpleLogRecordProcessor(
249-
new ExternalLogRecordExporterWrapper(externalLogExporter, externalTraceId)
250+
new ExternalLogRecordExporterWrapper(externalLogExporter, fallbackTraceId)
250251
)
251252
);
252253
}
@@ -393,10 +394,77 @@ function setLogLevel(level: TracingDiagnosticLogLevel) {
393394
diag.setLogger(new DiagConsoleLogger(), diagLogLevel);
394395
}
395396

397+
/**
398+
* A warm process serves runs one at a time, so this is only ever asked about
399+
* the current run and the tail of recently ended ones still draining.
400+
*/
401+
const MAX_TRACKED_INTERNAL_TRACES = 64;
402+
403+
/**
404+
* External trace ids for runs that carry no external trace context, one per
405+
* run.
406+
*
407+
* There has to be one per run because with `processKeepAlive` the `TracingSDK`
408+
* — and so the wrappers — outlive the run, so an id captured at construction
409+
* merges every run on the process into a single trace.
410+
*
411+
* Which id a record gets is decided by the record's own internal trace id
412+
* rather than by whatever run is current when the exporter is called. Batch
413+
* processors drain asynchronously, so a run's spans and logs are routinely
414+
* exported after the next run has already started; reading ambient state at
415+
* that point would stamp them with the wrong run's id. Keying off the record
416+
* also means spans and logs agree without having to coordinate.
417+
*/
418+
export class FallbackExternalTraceId {
419+
private readonly byInternalTrace = new Map<string, string>();
420+
421+
constructor(
422+
private seed: string,
423+
private traceIdGenerator: Pick<RandomIdGenerator, "generateTraceId"> = idGenerator
424+
) {}
425+
426+
/** False when no external trace id was configured, i.e. external export is off. */
427+
get enabled(): boolean {
428+
return !!this.seed;
429+
}
430+
431+
forInternalTrace(internalTraceId: string): string {
432+
// An empty seed means external export is disabled — leave it that way
433+
// rather than minting an id and switching the feature on.
434+
if (!this.seed) {
435+
return this.seed;
436+
}
437+
438+
const known = this.byInternalTrace.get(internalTraceId);
439+
440+
if (known) {
441+
return known;
442+
}
443+
444+
// The first run reuses the id generated at construction, so the configured
445+
// seed is not thrown away.
446+
const traceId =
447+
this.byInternalTrace.size === 0 ? this.seed : this.traceIdGenerator.generateTraceId();
448+
449+
this.byInternalTrace.set(internalTraceId, traceId);
450+
451+
if (this.byInternalTrace.size > MAX_TRACKED_INTERNAL_TRACES) {
452+
// Map iterates in insertion order, so this drops the oldest run.
453+
const oldest = this.byInternalTrace.keys().next().value;
454+
455+
if (oldest !== undefined) {
456+
this.byInternalTrace.delete(oldest);
457+
}
458+
}
459+
460+
return traceId;
461+
}
462+
}
463+
396464
export class ExternalSpanExporterWrapper {
397465
constructor(
398466
private underlyingExporter: SpanExporter,
399-
private externalTraceId: string
467+
private fallback: FallbackExternalTraceId
400468
) {}
401469

402470
private transformSpan(span: ReadableSpan): ReadableSpan | undefined {
@@ -407,7 +475,7 @@ export class ExternalSpanExporterWrapper {
407475

408476
const isExternallySampled = externalTraceContext
409477
? isTraceFlagSampled(externalTraceContext.traceFlags)
410-
: !!this.externalTraceId;
478+
: this.fallback.enabled;
411479

412480
if (!isExternallySampled) {
413481
return;
@@ -419,7 +487,7 @@ export class ExternalSpanExporterWrapper {
419487

420488
const externalTraceId = externalTraceContext
421489
? externalTraceContext.traceId
422-
: this.externalTraceId;
490+
: this.fallback.forInternalTrace(span.spanContext().traceId);
423491

424492
const isAttemptSpan = span.attributes[SemanticInternalAttributes.SPAN_ATTEMPT];
425493

@@ -477,18 +545,18 @@ export class ExternalSpanExporterWrapper {
477545
}
478546
}
479547

480-
class ExternalLogRecordExporterWrapper {
548+
export class ExternalLogRecordExporterWrapper {
481549
constructor(
482550
private underlyingExporter: LogRecordExporter,
483-
private externalTraceId: string
551+
private fallback: FallbackExternalTraceId
484552
) {}
485553

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

489557
const isExternallySampled = externalTraceContext
490558
? isTraceFlagSampled(externalTraceContext.traceFlags)
491-
: !!this.externalTraceId;
559+
: this.fallback.enabled;
492560

493561
if (!isExternallySampled) {
494562
this.underlyingExporter.export([], resultCallback);
@@ -519,14 +587,20 @@ class ExternalLogRecordExporterWrapper {
519587
| { traceId: string; spanId: string; tracestate?: string; traceFlags: number }
520588
| undefined
521589
): ReadableLogRecord {
522-
// Capture externalTraceId for use within the proxy's scope.
523-
// Use externalTraceContext.traceId if available, otherwise fall back to generated externalTraceId
590+
// Without a spanContext there is no internal trace id to key the fallback
591+
// on, and nothing to rewrite.
592+
if (!logRecord.spanContext) {
593+
return logRecord;
594+
}
595+
596+
// Capture externalTraceId for use within the proxy's scope. Use
597+
// externalTraceContext.traceId if available, otherwise the id belonging to
598+
// the run this record came from.
524599
const externalTraceId = externalTraceContext
525600
? externalTraceContext.traceId
526-
: this.externalTraceId;
601+
: this.fallback.forInternalTrace(logRecord.spanContext.traceId);
527602

528-
// If there's no spanContext, or if the externalTraceId is not set, return the original logRecord.
529-
if (!logRecord.spanContext || !externalTraceId) {
603+
if (!externalTraceId) {
530604
return logRecord;
531605
}
532606

0 commit comments

Comments
 (0)