Skip to content

Commit 854bf38

Browse files
d-csclaude
andcommitted
fix(webapp): seed mollifier run traceContext + propagate drainer trace
Mollified runs were materialising with `TaskRun.traceContext = {}`, so every downstream `recordRunDebugLog` (engine QUEUED/EXECUTING/FINISHED, run:notify, attempt events) drew a fresh traceId with null parentId. The run-detail trace view rendered only the root span; the rest of the tree was orphaned. The pass-through path gets traceContext for free via `traceEventConcern.traceRun` populating the W3C traceparent. The mollifier path skips that wrapper, so seed `traceContext.traceparent` from the queued span at the call site before handing the snapshot to engine.trigger. Also fixes the drainer side: wrap the `mollifier.drained` span + `engine.trigger` call in a `context.with(parentContext, ...)` built from the snapshot's traceId/spanId. Without this `mollifier.drained` lived in a fresh trace and the engine instrumentation inside it inherited an empty active context. Regression tests: - `triggerTask.test.ts` — asserts the buffered snapshot carries a valid W3C traceparent that references the snapshot's traceId/spanId. - `mollifierDrainerHandler.test.ts` — captures the active traceId at the moment engine.trigger is invoked and asserts it matches the snapshot's traceId. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 175d759 commit 854bf38

4 files changed

Lines changed: 98 additions & 9 deletions

File tree

apps/webapp/app/runEngine/services/triggerTask.server.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,11 +388,23 @@ export class RunEngineTriggerTaskService {
388388
mollifierOutcome.decision.threshold
389389
);
390390
mollifierSpan.setAttribute("runId", runFriendlyId);
391+
mollifierSpan.setAttribute("taskRunId", runFriendlyId);
391392

392393
const payloadPacket = await this.payloadProcessor.process(triggerRequest);
393394
const taskEventStore = parentRun?.taskEventStore ?? "taskEvent";
395+
// Seed the W3C `traceparent` from the queued span so downstream
396+
// `recordRunDebugLog` calls (engine QUEUED/EXECUTING/FINISHED,
397+
// run:notify, etc.) emit TaskEvent rows that join the run's trace.
398+
// Pass-through gets this for free via `traceEventConcern.traceRun`
399+
// populating `event.traceContext`; the mollifier path skips that
400+
// wrapper so we have to build the same shape ourselves.
394401
const traceContext = this.#propagateExternalTraceContext(
395-
{},
402+
{
403+
traceparent: serializeTraceparent(
404+
mollifierSpan.spanContext().traceId,
405+
mollifierSpan.spanContext().spanId
406+
),
407+
},
396408
parentRun?.traceContext,
397409
undefined
398410
);

apps/webapp/app/v3/mollifier/mollifierDrainerHandler.server.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { trace } from "@opentelemetry/api";
1+
import { context, trace, TraceFlags } from "@opentelemetry/api";
22
import type { RunEngine } from "@internal/run-engine";
33
import type { PrismaClientOrTransaction } from "@trigger.dev/database";
44
import type { MollifierDrainerHandler } from "@trigger.dev/redis-worker";
@@ -25,13 +25,36 @@ export function createDrainerHandler(deps: {
2525
return async (input) => {
2626
const dwellMs = Date.now() - input.createdAt.getTime();
2727

28-
await startSpan(tracer, "mollifier.drained", async (span) => {
29-
span.setAttribute("mollifier.drained", true);
30-
span.setAttribute("mollifier.dwell_ms", dwellMs);
31-
span.setAttribute("mollifier.attempts", input.attempts);
32-
span.setAttribute("mollifier.run_friendly_id", input.runId);
28+
// Re-attach to the trace started by the caller's mollifier.queued span
29+
// (its traceId + spanId were captured into the snapshot at buffer time).
30+
// Without this the drainer would emit mollifier.drained in a brand-new
31+
// trace and the engine.trigger instrumentation would inherit an empty
32+
// active context — leaving the run-detail page with only the root span.
33+
const snapshotTraceId =
34+
typeof input.payload.traceId === "string" ? input.payload.traceId : undefined;
35+
const snapshotSpanId =
36+
typeof input.payload.spanId === "string" ? input.payload.spanId : undefined;
3337

34-
await deps.engine.trigger(input.payload as any, deps.prisma);
38+
const parentContext =
39+
snapshotTraceId && snapshotSpanId
40+
? trace.setSpanContext(context.active(), {
41+
traceId: snapshotTraceId,
42+
spanId: snapshotSpanId,
43+
traceFlags: TraceFlags.SAMPLED,
44+
isRemote: true,
45+
})
46+
: context.active();
47+
48+
await context.with(parentContext, async () => {
49+
await startSpan(tracer, "mollifier.drained", async (span) => {
50+
span.setAttribute("mollifier.drained", true);
51+
span.setAttribute("mollifier.dwell_ms", dwellMs);
52+
span.setAttribute("mollifier.attempts", input.attempts);
53+
span.setAttribute("mollifier.run_friendly_id", input.runId);
54+
span.setAttribute("taskRunId", input.runId);
55+
56+
await deps.engine.trigger(input.payload as any, deps.prisma);
57+
});
3558
});
3659
};
3760
}

apps/webapp/test/engine/triggerTask.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1355,7 +1355,26 @@ describe("RunEngineTriggerTaskService", () => {
13551355
// input). Schema is internal to the engine, so we only assert that
13561356
// it parses and references the friendlyId — anything more specific
13571357
// would couple the mollifier-layer test to engine-layer fields.
1358-
expect(() => JSON.parse(buffer.accepted[0]!.payload)).not.toThrow();
1358+
const snapshot = JSON.parse(buffer.accepted[0]!.payload) as {
1359+
traceId?: string;
1360+
spanId?: string;
1361+
traceContext?: { traceparent?: string };
1362+
};
1363+
1364+
// Regression guard for the dashboard trace-tree bug: the mollifier
1365+
// snapshot MUST carry a W3C `traceparent` in `traceContext`, seeded
1366+
// from the queued span. Without it, the drainer replays through
1367+
// engine.trigger with empty traceContext and every downstream
1368+
// `recordRunDebugLog` (QUEUED/EXECUTING/FINISHED/run:notify…) gets a
1369+
// fresh traceId + null parentId — the run-detail page can only show
1370+
// the root span. Pass-through gets this for free via
1371+
// `traceEventConcern.traceRun`; the mollifier path doesn't enter
1372+
// that wrapper so the seeding has to happen at the call site.
1373+
expect(snapshot.traceContext?.traceparent).toMatch(
1374+
/^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/
1375+
);
1376+
expect(snapshot.traceContext!.traceparent).toContain(snapshot.traceId);
1377+
expect(snapshot.traceContext!.traceparent).toContain(snapshot.spanId);
13591378

13601379
// Postgres has NOT been written: engine.trigger was never called on
13611380
// the mollify path. The run materialises only when the drainer

apps/webapp/test/mollifierDrainerHandler.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, expect, it, vi } from "vitest";
2+
import { trace } from "@opentelemetry/api";
23

34
vi.mock("~/db.server", () => ({
45
prisma: {},
@@ -55,6 +56,40 @@ describe("createDrainerHandler", () => {
5556
expect(callArg.taskIdentifier).toBe("t");
5657
});
5758

59+
it("re-attaches the snapshot's traceId so engine.trigger inherits the original trace", async () => {
60+
// Captures the active traceId at the moment engine.trigger is invoked.
61+
// Without context propagation it would be a fresh traceId, leaving the
62+
// run-detail page with only the root span.
63+
let observedTraceId: string | undefined;
64+
const trigger = vi.fn(async () => {
65+
observedTraceId = trace.getActiveSpan()?.spanContext().traceId;
66+
return { friendlyId: "run_x" };
67+
});
68+
69+
const handler = createDrainerHandler({
70+
engine: { trigger } as any,
71+
prisma: {} as any,
72+
});
73+
74+
const snapshotTraceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
75+
const snapshotSpanId = "bbbbbbbbbbbbbbbb";
76+
77+
await handler({
78+
runId: "run_x",
79+
envId: "env_a",
80+
orgId: "org_1",
81+
payload: {
82+
taskIdentifier: "t",
83+
traceId: snapshotTraceId,
84+
spanId: snapshotSpanId,
85+
},
86+
attempts: 0,
87+
createdAt: new Date(),
88+
} as any);
89+
90+
expect(observedTraceId).toBe(snapshotTraceId);
91+
});
92+
5893
it("propagates engine.trigger errors so MollifierDrainer can classify them", async () => {
5994
const trigger = vi.fn(async () => {
6095
throw new Error("boom");

0 commit comments

Comments
 (0)