Skip to content

Commit 82e03ee

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 7b390e5 commit 82e03ee

3 files changed

Lines changed: 259 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: 83 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,70 @@ function setLogLevel(level: TracingDiagnosticLogLevel) {
393394
diag.setLogger(new DiagConsoleLogger(), diagLogLevel);
394395
}
395396

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

402463
private transformSpan(span: ReadableSpan): ReadableSpan | undefined {
@@ -407,7 +468,7 @@ export class ExternalSpanExporterWrapper {
407468

408469
const isExternallySampled = externalTraceContext
409470
? isTraceFlagSampled(externalTraceContext.traceFlags)
410-
: !!this.externalTraceId;
471+
: this.fallback.enabled;
411472

412473
if (!isExternallySampled) {
413474
return;
@@ -419,7 +480,7 @@ export class ExternalSpanExporterWrapper {
419480

420481
const externalTraceId = externalTraceContext
421482
? externalTraceContext.traceId
422-
: this.externalTraceId;
483+
: this.fallback.forInternalTrace(span.spanContext().traceId);
423484

424485
const isAttemptSpan = span.attributes[SemanticInternalAttributes.SPAN_ATTEMPT];
425486

@@ -477,18 +538,18 @@ export class ExternalSpanExporterWrapper {
477538
}
478539
}
479540

480-
class ExternalLogRecordExporterWrapper {
541+
export class ExternalLogRecordExporterWrapper {
481542
constructor(
482543
private underlyingExporter: LogRecordExporter,
483-
private externalTraceId: string
544+
private fallback: FallbackExternalTraceId
484545
) {}
485546

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

489550
const isExternallySampled = externalTraceContext
490551
? isTraceFlagSampled(externalTraceContext.traceFlags)
491-
: !!this.externalTraceId;
552+
: this.fallback.enabled;
492553

493554
if (!isExternallySampled) {
494555
this.underlyingExporter.export([], resultCallback);
@@ -519,14 +580,20 @@ class ExternalLogRecordExporterWrapper {
519580
| { traceId: string; spanId: string; tracestate?: string; traceFlags: number }
520581
| undefined
521582
): ReadableLogRecord {
522-
// Capture externalTraceId for use within the proxy's scope.
523-
// Use externalTraceContext.traceId if available, otherwise fall back to generated externalTraceId
583+
// Without a spanContext there is no internal trace id to key the fallback
584+
// on, and nothing to rewrite.
585+
if (!logRecord.spanContext) {
586+
return logRecord;
587+
}
588+
589+
// Capture externalTraceId for use within the proxy's scope. Use
590+
// externalTraceContext.traceId if available, otherwise the id belonging to
591+
// the run this record came from.
524592
const externalTraceId = externalTraceContext
525593
? externalTraceContext.traceId
526-
: this.externalTraceId;
594+
: this.fallback.forInternalTrace(logRecord.spanContext.traceId);
527595

528-
// If there's no spanContext, or if the externalTraceId is not set, return the original logRecord.
529-
if (!logRecord.spanContext || !externalTraceId) {
596+
if (!externalTraceId) {
530597
return logRecord;
531598
}
532599

0 commit comments

Comments
 (0)