Skip to content

Commit dfdcf0d

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 dfdcf0d

3 files changed

Lines changed: 293 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 fallbackTraceIds = new FallbackExternalTraceIds(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, fallbackTraceIds), {
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, fallbackTraceIds))
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, fallbackTraceIds),
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, fallbackTraceIds)
250251
)
251252
);
252253
}
@@ -393,10 +394,77 @@ 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 FallbackExternalTraceIds {
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+
// Re-insert so the map is ordered by last use rather than first. A run
435+
// that is still exporting keeps its id even if enough unrelated traces
436+
// appear alongside it to fill the map, which would otherwise split it
437+
// across two external traces.
438+
this.byInternalTrace.delete(internalTraceId);
439+
this.byInternalTrace.set(internalTraceId, known);
440+
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 least recently used.
453+
const stalest = this.byInternalTrace.keys().next().value;
454+
455+
if (stalest !== undefined) {
456+
this.byInternalTrace.delete(stalest);
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: FallbackExternalTraceIds
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: FallbackExternalTraceIds
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)