Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/external-trace-id-per-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Release note text describes internals instead of user-visible behaviour

The release note added for this change leads with implementation detail and names an internal component ("Mint the fallback external trace id per run rather than once per TracingSDK" in .changeset/external-trace-id-per-run.md:5), which the repository guidelines forbid for user-facing notes.

Impact: Users reading the release notes see internal jargon rather than a plain description of what changed for them.

Rule reference

AGENTS.md, section "Changesets and Server Changes": "Write the description for users, not maintainers. ... Lead with what changed for the user - one plain sentence describing behavior, not implementation, and never naming internal tools or infra."

Suggested change
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 runso 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.
Runs that don't start from an incoming trace are no longer merged together when they run on the same warm worker processeach run now gets its own trace in your own observability tool.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

80 changes: 67 additions & 13 deletions packages/core/src/v3/otel/tracingSDK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,21 +393,67 @@ function setLogLevel(level: TracingDiagnosticLogLevel) {
diag.setLogger(new DiagConsoleLogger(), diagLogLevel);
}

/**
* The external trace id used by runs that carry no external trace context,
* minted once per run.
*
* It has to change per run for the same reason the wrappers read the external
* context live: with `processKeepAlive` the `TracingSDK` — and so the wrappers
* — outlive the run, so an id captured at construction merges every run on the
* process into one trace. The manager's trace context object is reassigned per
* run, which makes its identity the run boundary.
*/
class FallbackExternalTraceId {
private traceId: string;
private seenTraceContext: unknown;

constructor(
private seed: string,
private traceIdGenerator: Pick<RandomIdGenerator, "generateTraceId"> = idGenerator
) {
this.traceId = seed;
this.seenTraceContext = traceContext.getTraceContext();
}

get(): string {
// An empty seed means external export is disabled — leave it that way
// rather than minting an id and switching the feature on.
if (!this.seed) {
return this.seed;
}

const currentTraceContext = traceContext.getTraceContext();

if (currentTraceContext !== this.seenTraceContext) {
this.seenTraceContext = currentTraceContext;
this.traceId = this.traceIdGenerator.generateTraceId();
}
Comment on lines +425 to +430

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Run-boundary detection degrades to "every export" when no trace context manager is registered

The remint trigger is reference-identity of traceContext.getTraceContext(). When no manager has been registered, the API falls back to NoopTraceContextManager, whose getTraceContext() returns a freshly allocated {} on every call (packages/core/src/v3/traceContext/index.ts), so currentTraceContext !== this.seenTraceContext is always true and a brand-new fallback trace id is minted on every single export batch — shattering one logical trace into many.

Today this is not reachable in production: the only TracingSDK instances that receive exporters/logExporters are in packages/cli-v3/src/entryPoints/managed-run-worker.ts:215 and dev-run-worker.ts:243, both of which register StandardTraceContextManager beforehand; the index workers (managed-index-worker.ts:95, dev-index-worker.ts:101) construct TracingSDK without external exporters, so no wrapper is created. Still, the invariant "a manager is always registered" is implicit and unguarded — a cheap defence would be to treat an empty/noop context as "no change".

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


return this.traceId;
}
}

export class ExternalSpanExporterWrapper {
private fallback: FallbackExternalTraceId;

constructor(
private underlyingExporter: SpanExporter,
private externalTraceId: string
) {}
externalTraceId: string,
traceIdGenerator?: Pick<RandomIdGenerator, "generateTraceId">
) {
this.fallback = new FallbackExternalTraceId(externalTraceId, traceIdGenerator);
}

private transformSpan(span: ReadableSpan): ReadableSpan | undefined {
// Read external context live, so per-run reassignment of
// standardTraceContextManager.traceContext is honoured on warm-started
// workers that reuse a single TracingSDK across runs.
const externalTraceContext = traceContext.getExternalTraceContext();
const fallbackTraceId = this.fallback.get();

const isExternallySampled = externalTraceContext
? isTraceFlagSampled(externalTraceContext.traceFlags)
: !!this.externalTraceId;
: !!fallbackTraceId;

if (!isExternallySampled) {
return;
Expand All @@ -419,7 +465,7 @@ export class ExternalSpanExporterWrapper {

const externalTraceId = externalTraceContext
? externalTraceContext.traceId
: this.externalTraceId;
: fallbackTraceId;

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

Expand Down Expand Up @@ -478,25 +524,33 @@ export class ExternalSpanExporterWrapper {
}

class ExternalLogRecordExporterWrapper {
private fallback: FallbackExternalTraceId;

constructor(
private underlyingExporter: LogRecordExporter,
private externalTraceId: string
) {}
externalTraceId: string,
traceIdGenerator?: Pick<RandomIdGenerator, "generateTraceId">
) {
this.fallback = new FallbackExternalTraceId(externalTraceId, traceIdGenerator);
}
Comment on lines +527 to +535

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Logs and traces sent to external observability tools stop lining up for runs without incoming trace context

Each exporter builds its own private generator of the substitute trace id (new FallbackExternalTraceId(...) at packages/core/src/v3/otel/tracingSDK.ts:534) instead of sharing one, so from the second run onwards on a reused process the logs and the spans of the same run are stamped with different, randomly generated ids.

Impact: In the customer's own observability backend, a run's logs are no longer attached to that run's trace, so they appear orphaned and undiscoverable.

Why the two wrappers diverge after the first run

Before this change, TracingSDK generated one externalTraceId string (packages/core/src/v3/otel/tracingSDK.ts:165) and passed the same string to every ExternalSpanExporterWrapper (packages/core/src/v3/otel/tracingSDK.ts:170,182) and every ExternalLogRecordExporterWrapper (packages/core/src/v3/otel/tracingSDK.ts:234,249). Spans and logs therefore always agreed on the fallback trace id.

Now each wrapper constructs its own FallbackExternalTraceId holding independent state (packages/core/src/v3/otel/tracingSDK.ts:444 and :534). On the first run all instances still return the shared seed, but as soon as traceContext.getTraceContext() identity changes (a new run), each instance independently calls traceIdGenerator.generateTraceId() (packages/core/src/v3/otel/tracingSDK.ts:429), producing a different random id per wrapper. The same divergence occurs when a user configures more than one entry in telemetry.exporters.

A shared FallbackExternalTraceId instance created once in the TracingSDK constructor and passed to all wrappers would keep the per-run remint while preserving cross-signal correlation.

Prompt for agents
In packages/core/src/v3/otel/tracingSDK.ts, the new FallbackExternalTraceId is instantiated separately inside ExternalSpanExporterWrapper and ExternalLogRecordExporterWrapper. Previously all wrappers received one identical externalTraceId string generated once in the TracingSDK constructor, which guaranteed that spans and logs of a run without external trace context shared the same external trace id. With per-wrapper instances, the first remint (second run on a warm process) makes each wrapper generate its own random trace id, so a run's logs and spans no longer correlate in the external backend; multiple configured span exporters diverge too. Consider constructing a single FallbackExternalTraceId in the TracingSDK constructor (seeded with the generated id) and injecting that shared instance into every ExternalSpanExporterWrapper and ExternalLogRecordExporterWrapper, keeping the existing per-run remint semantics and the tests' ability to inject a fake id generator.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


export(logs: any[], resultCallback: (result: any) => void): void {
const externalTraceContext = traceContext.getExternalTraceContext();
const fallbackTraceId = this.fallback.get();

const isExternallySampled = externalTraceContext
? isTraceFlagSampled(externalTraceContext.traceFlags)
: !!this.externalTraceId;
: !!fallbackTraceId;

if (!isExternallySampled) {
this.underlyingExporter.export([], resultCallback);

return;
}

const modifiedLogs = logs.map((log) => this.transformLogRecord(log, externalTraceContext));
const modifiedLogs = logs.map((log) =>
this.transformLogRecord(log, externalTraceContext, fallbackTraceId)
);

this.underlyingExporter.export(modifiedLogs, resultCallback);
}
Expand All @@ -517,13 +571,13 @@ class ExternalLogRecordExporterWrapper {
logRecord: ReadableLogRecord,
externalTraceContext:
| { traceId: string; spanId: string; tracestate?: string; traceFlags: number }
| undefined
| undefined,
fallbackTraceId: string
): ReadableLogRecord {
// Capture externalTraceId for use within the proxy's scope.
// Use externalTraceContext.traceId if available, otherwise fall back to generated externalTraceId
const externalTraceId = externalTraceContext
? externalTraceContext.traceId
: this.externalTraceId;
// Use externalTraceContext.traceId if available, otherwise fall back to the
// per-run generated id.
const externalTraceId = externalTraceContext ? externalTraceContext.traceId : fallbackTraceId;

// If there's no spanContext, or if the externalTraceId is not set, return the original logRecord.
if (!logRecord.spanContext || !externalTraceId) {
Expand Down
81 changes: 81 additions & 0 deletions packages/core/test/externalSpanExporterWrapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ describe("ExternalSpanExporterWrapper warm-start regression", () => {
let manager: StandardTraceContextManager;

beforeEach(() => {
// `setGlobalManager` delegates to `registerGlobal`, which ignores a second
// registration — without disabling first, every test after the first would
// keep mutating the first test's manager.
traceContext.disable();
manager = new StandardTraceContextManager();
traceContext.setGlobalManager(manager);
});
Expand All @@ -77,4 +81,81 @@ describe("ExternalSpanExporterWrapper warm-start regression", () => {
expect(span.parentSpanContext?.traceId).toBe("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
expect(span.spanContext().traceId).toBe("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
});

// Runs triggered internally — a schedule, or one task triggering another —
// carry no external trace context and so take the generated fallback. That id
// was captured at construction, which on a warm-started worker meant every run
// on the process shared a single trace id.
it("mints a new fallback trace id per run when there is no external context", () => {
const { exporter, captured } = makeCapturingExporter();

let generated = 0;
const idGenerator = {
generateTraceId: () => `${++generated}`.padStart(32, "0"),
};

manager.traceContext = { traceparent: TRACEPARENT_RUN_A };

const wrapper = new ExternalSpanExporterWrapper(
exporter,
"ffffffffffffffffffffffffffffffff",
idGenerator
);

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

// A second run on the same warm process: the manager is reassigned, so the
// fallback has to be reminted.
manager.traceContext = { traceparent: TRACEPARENT_RUN_B };

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

const runATraceId = captured[0]![0]!.spanContext().traceId;
const runBTraceId = captured[1]![0]!.spanContext().traceId;

expect(runATraceId).toBe("ffffffffffffffffffffffffffffffff");
expect(runBTraceId).not.toBe(runATraceId);
expect(runBTraceId).toBe("00000000000000000000000000000001");
});

it("keeps one fallback trace id across every export within a run", () => {
const { exporter, captured } = makeCapturingExporter();

let generated = 0;
const idGenerator = {
generateTraceId: () => `${++generated}`.padStart(32, "0"),
};

manager.traceContext = { traceparent: TRACEPARENT_RUN_A };

const wrapper = new ExternalSpanExporterWrapper(
exporter,
"ffffffffffffffffffffffffffffffff",
idGenerator
);

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

expect(captured[1]![0]!.spanContext().traceId).toBe(captured[0]![0]!.spanContext().traceId);
expect(generated).toBe(0);
});

it("leaves external export off when no external trace id was configured", () => {
const { exporter, captured } = makeCapturingExporter();

const idGenerator = {
generateTraceId: () => "00000000000000000000000000000001",
};

manager.traceContext = { traceparent: TRACEPARENT_RUN_A };

const wrapper = new ExternalSpanExporterWrapper(exporter, "", idGenerator);

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

// Minting an id here would switch external export on for a deployment that
// never asked for it.
expect(captured[0]).toHaveLength(0);
});
});
Loading