From ee457aae12282d26480abc2d20875f4e18a0b222 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 2 Dec 2025 14:01:04 +0000 Subject: [PATCH] fix(otel): exported logs and spans will now have matching trace IDs When using custom OTLP exporters via `telemetry.exporters` and This occurred when tasks were triggered **without** a parent trace context (e.g., via API or dashboard). In this scenario: - Spans were correctly rewritten to use the generated `externalTraceId` - Logs kept their original internal trace ID due to a bug in the early return logic ### Root Cause In `ExternalLogRecordExporterWrapper.transformLogRecord()`, the early return condition incorrectly included `!this.externalTraceContext`: ```typescript if (!logRecord.spanContext || !this.externalTraceId || !this.externalTraceContext) { return logRecord; // Bug: Returns early when externalTraceContext is undefined } // This fallback logic was never reached: const externalTraceId = this.externalTraceContext ? this.externalTraceContext.traceId : this.externalTraceId; ``` ### Fix 1. **Reordered logic in `transformLogRecord()`**: Move the 1. `externalTraceId` calculation before the early return, and check the 1. culated value instead of `this.externalTraceContext`: ```typescript const externalTraceId = this.externalTraceContext ? this.externalTraceContext.traceId : this.externalTraceId; if (!logRecord.spanContext || !externalTraceId) { return logRecord; } ``` 2. **Clarified `_isExternallySampled` logic**: Updated both 2. `ExternalSpanExporterWrapper` and `ExternalLogRecordExporterWrapper` 2. explicitly handle the case where there's no external trace context 2. a generated `externalTraceId` exists: ```typescript this._isExternallySampled = externalTraceContext ? isTraceFlagSampled(externalTraceContext.traceFlags) : !!externalTraceId; ``` ### Impact Logs and spans from the same task run will now have matching trace IDs when exported to external observability tools, enabling proper trace correlation regardless of whether the task was triggered with or without a parent trace context. `telemetry.logExporters` in `trigger.config.ts`, logs and spans were exported with **different trace IDs**, breaking trace correlation in external observability tools like Datadog. --- .changeset/brown-pots-beg.md | 5 +++++ packages/core/src/v3/otel/tracingSDK.ts | 19 ++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 .changeset/brown-pots-beg.md diff --git a/.changeset/brown-pots-beg.md b/.changeset/brown-pots-beg.md new file mode 100644 index 0000000000..2ac54b76f1 --- /dev/null +++ b/.changeset/brown-pots-beg.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +fix(otel): exported logs and spans will now have matching trace IDs diff --git a/packages/core/src/v3/otel/tracingSDK.ts b/packages/core/src/v3/otel/tracingSDK.ts index 9bfd098ffd..694212f71b 100644 --- a/packages/core/src/v3/otel/tracingSDK.ts +++ b/packages/core/src/v3/otel/tracingSDK.ts @@ -314,7 +314,9 @@ class ExternalSpanExporterWrapper { | { traceId: string; spanId: string; traceFlags: number; tracestate?: string } | undefined ) { - this._isExternallySampled = isTraceFlagSampled(externalTraceContext?.traceFlags); + this._isExternallySampled = externalTraceContext + ? isTraceFlagSampled(externalTraceContext.traceFlags) + : !!externalTraceId; } private transformSpan(span: ReadableSpan): ReadableSpan | undefined { @@ -396,7 +398,9 @@ class ExternalLogRecordExporterWrapper { | { traceId: string; spanId: string; tracestate?: string; traceFlags: number } | undefined ) { - this._isExternallySampled = isTraceFlagSampled(externalTraceContext?.traceFlags); + this._isExternallySampled = externalTraceContext + ? isTraceFlagSampled(externalTraceContext.traceFlags) + : !!externalTraceId; } export(logs: any[], resultCallback: (result: any) => void): void { @@ -416,16 +420,17 @@ class ExternalLogRecordExporterWrapper { } transformLogRecord(logRecord: ReadableLogRecord): ReadableLogRecord { - // If there's no spanContext, or if the externalTraceId is not set, return the original logRecord. - if (!logRecord.spanContext || !this.externalTraceId || !this.externalTraceContext) { - return logRecord; - } - // Capture externalTraceId for use within the proxy's scope. + // Use externalTraceContext.traceId if available, otherwise fall back to generated externalTraceId const externalTraceId = this.externalTraceContext ? this.externalTraceContext.traceId : this.externalTraceId; + // If there's no spanContext, or if the externalTraceId is not set, return the original logRecord. + if (!logRecord.spanContext || !externalTraceId) { + return logRecord; + } + return new Proxy(logRecord, { get(target, prop, receiver) { if (prop === "spanContext") {