From c5c65b06b6d44acc1143821a7f859241f3fc4244 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 16 Mar 2026 12:49:51 -0700 Subject: [PATCH 1/8] fix trace viewer for v1 events --- .../trace-viewer/components/span-segments.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/web-shared/src/components/trace-viewer/components/span-segments.ts b/packages/web-shared/src/components/trace-viewer/components/span-segments.ts index b15de8f4d2..d205091b8e 100644 --- a/packages/web-shared/src/components/trace-viewer/components/span-segments.ts +++ b/packages/web-shared/src/components/trace-viewer/components/span-segments.ts @@ -344,6 +344,14 @@ function computeRunSegments(node: SpanNode): Segment[] { const failedEvent = events.find((e) => e.event.name === 'run_failed'); const completedEvent = events.find((e) => e.event.name === 'run_completed'); + // For v1 runs (specVersion 1), run lifecycle events (run_created, + // run_started, run_completed) are not present in the event log. Fall back + // to the run entity's status stored in span.attributes.data. + const runData = node.span.attributes?.data as + | Record + | undefined; + const runStatus = runData?.status as string | undefined; + // Queued period (from span start to activeStartTime) let cursor = 0; if (activeStartTime && activeStartTime > startTime) { @@ -395,6 +403,18 @@ function computeRunSegments(node: SpanNode): Segment[] { endFraction: 1, status: 'succeeded', }); + } else if (runStatus === 'failed') { + segments.push({ + startFraction: cursor, + endFraction: 1, + status: 'failed', + }); + } else if (runStatus === 'completed' || runStatus === 'cancelled') { + segments.push({ + startFraction: cursor, + endFraction: 1, + status: 'succeeded', + }); } else { // Running to completion segments.push({ From daca67cb3b03ec14b9b4465732f8fe942cf21112 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 16 Mar 2026 12:51:59 -0700 Subject: [PATCH 2/8] add changeset --- .changeset/brown-cobras-raise.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/brown-cobras-raise.md diff --git a/.changeset/brown-cobras-raise.md b/.changeset/brown-cobras-raise.md new file mode 100644 index 0000000000..c66f6a66bd --- /dev/null +++ b/.changeset/brown-cobras-raise.md @@ -0,0 +1,5 @@ +--- +"@workflow/web-shared": patch +--- + +Fix trace viewer construction of traces for v1 runs From 48e69090459757f5872b74aad9db98a6d72e62da Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 16 Mar 2026 12:55:31 -0700 Subject: [PATCH 3/8] add a test --- .../src/lib/trace-builder-v1.test.ts | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 packages/web-shared/src/lib/trace-builder-v1.test.ts diff --git a/packages/web-shared/src/lib/trace-builder-v1.test.ts b/packages/web-shared/src/lib/trace-builder-v1.test.ts new file mode 100644 index 0000000000..b1d48b09a5 --- /dev/null +++ b/packages/web-shared/src/lib/trace-builder-v1.test.ts @@ -0,0 +1,211 @@ +import type { Event, WorkflowRun } from '@workflow/world'; +import { describe, expect, it } from 'vitest'; +import { computeSegments } from '../components/trace-viewer/components/span-segments.js'; +import { parseTrace } from '../components/trace-viewer/util/tree.js'; +import { buildTrace, groupEventsByCorrelation } from './trace-builder.js'; + +const BASE_TIME = new Date('2026-03-16T00:00:00Z'); +const STARTED_TIME = new Date('2026-03-16T00:00:01Z'); +const COMPLETED_TIME = new Date('2026-03-16T00:00:10Z'); + +function makeV1Run(overrides: Partial = {}): WorkflowRun { + return { + runId: 'wrun_v1test', + deploymentId: 'dep_1', + workflowName: 'v1-workflow', + specVersion: 1, + input: {}, + createdAt: BASE_TIME, + updatedAt: COMPLETED_TIME, + startedAt: STARTED_TIME, + completedAt: COMPLETED_TIME, + status: 'completed', + output: { result: 'ok' }, + error: undefined, + executionContext: {}, + expiredAt: undefined, + ...overrides, + } as WorkflowRun; +} + +function makeStepEvents( + correlationId: string, + stepName: string, + startOffset: number, + endOffset: number +): Event[] { + return [ + { + eventId: `evnt_${correlationId}_created`, + runId: 'wrun_v1test', + eventType: 'step_created', + correlationId, + createdAt: new Date(BASE_TIME.getTime() + startOffset), + specVersion: 1, + eventData: { stepName, input: {} }, + }, + { + eventId: `evnt_${correlationId}_started`, + runId: 'wrun_v1test', + eventType: 'step_started', + correlationId, + createdAt: new Date(BASE_TIME.getTime() + startOffset + 100), + specVersion: 1, + }, + { + eventId: `evnt_${correlationId}_completed`, + runId: 'wrun_v1test', + eventType: 'step_completed', + correlationId, + createdAt: new Date(BASE_TIME.getTime() + endOffset), + specVersion: 1, + eventData: { result: 42 }, + }, + ] as Event[]; +} + +describe('Trace viewer with v1 events (no run lifecycle events)', () => { + describe('groupEventsByCorrelation', () => { + it('groups step events with no run-level events for v1', () => { + const events = makeStepEvents('step_1', 'add', 1000, 3000); + const grouped = groupEventsByCorrelation(events); + + expect(grouped.runLevelEvents).toHaveLength(0); + expect(grouped.eventsByStepId.size).toBe(1); + expect(grouped.eventsByStepId.get('step_1')).toHaveLength(3); + }); + }); + + describe('buildTrace', () => { + it('builds a valid trace for a completed v1 run with step events', () => { + const run = makeV1Run({ status: 'completed' }); + const events = makeStepEvents('step_1', 'add', 1000, 3000); + const now = new Date('2026-03-16T00:01:00Z'); + const trace = buildTrace(run, events, now); + + expect(trace.traceId).toBe('wrun_v1test'); + expect(trace.rootSpanId).toBe('wrun_v1test'); + expect(trace.spans).toHaveLength(2); + + const runSpan = trace.spans.find((s) => s.spanId === 'wrun_v1test'); + expect(runSpan).toBeDefined(); + expect(runSpan!.attributes.resource).toBe('run'); + expect(runSpan!.attributes.data).toMatchObject({ + status: 'completed', + completedAt: COMPLETED_TIME, + }); + }); + + it('builds a valid trace for a failed v1 run', () => { + const run = makeV1Run({ + status: 'failed', + output: undefined, + error: { message: 'boom', name: 'Error' }, + }); + const events = makeStepEvents('step_1', 'add', 1000, 3000); + const trace = buildTrace(run, events, new Date()); + + const runSpan = trace.spans.find((s) => s.spanId === 'wrun_v1test'); + expect(runSpan!.attributes.data).toMatchObject({ status: 'failed' }); + }); + + it('builds a valid trace for a v1 run with no events at all', () => { + const run = makeV1Run({ status: 'completed' }); + const trace = buildTrace(run, [], new Date()); + + expect(trace.spans).toHaveLength(1); + expect(trace.spans[0].spanId).toBe('wrun_v1test'); + expect(trace.spans[0].attributes.resource).toBe('run'); + }); + }); + + describe('computeSegments for v1 run spans', () => { + it('shows "succeeded" segment for a completed v1 run (no run_completed event)', () => { + const run = makeV1Run({ status: 'completed' }); + const events = makeStepEvents('step_1', 'add', 1000, 3000); + const trace = buildTrace(run, events, new Date()); + const { map } = parseTrace(trace); + + const runNode = map[run.runId]; + expect(runNode).toBeDefined(); + + const result = computeSegments('run', runNode); + expect(result.segments.length).toBeGreaterThan(0); + + const lastSegment = result.segments[result.segments.length - 1]; + expect(lastSegment.status).toBe('succeeded'); + expect(lastSegment.endFraction).toBe(1); + }); + + it('shows "failed" segment for a failed v1 run (no run_failed event)', () => { + const run = makeV1Run({ + status: 'failed', + output: undefined, + error: { message: 'boom', name: 'Error' }, + }); + const events = makeStepEvents('step_1', 'add', 1000, 3000); + const trace = buildTrace(run, events, new Date()); + const { map } = parseTrace(trace); + + const runNode = map[run.runId]; + const result = computeSegments('run', runNode); + + const lastSegment = result.segments[result.segments.length - 1]; + expect(lastSegment.status).toBe('failed'); + expect(lastSegment.endFraction).toBe(1); + }); + + it('shows "running" segment for an in-progress v1 run', () => { + const run = makeV1Run({ + status: 'running', + completedAt: undefined, + output: undefined, + }); + const events = makeStepEvents('step_1', 'add', 1000, 3000); + const now = new Date('2026-03-16T00:01:00Z'); + const trace = buildTrace(run, events, now); + const { map } = parseTrace(trace); + + const runNode = map[run.runId]; + const result = computeSegments('run', runNode); + + const lastSegment = result.segments[result.segments.length - 1]; + expect(lastSegment.status).toBe('running'); + }); + + it('shows queued + succeeded for a v1 run with startedAt', () => { + const run = makeV1Run({ status: 'completed', startedAt: STARTED_TIME }); + const trace = buildTrace(run, [], new Date()); + const { map } = parseTrace(trace); + + const runNode = map[run.runId]; + const result = computeSegments('run', runNode); + + expect(result.segments.length).toBe(2); + expect(result.segments[0].status).toBe('queued'); + expect(result.segments[1].status).toBe('succeeded'); + }); + + it('v2 baseline: shows "succeeded" from run_completed event', () => { + const run = makeV1Run({ specVersion: 2, status: 'completed' }); + const stepEvents = makeStepEvents('step_1', 'add', 1000, 3000); + const runCompletedEvent: Event = { + eventId: 'evnt_run_completed', + runId: 'wrun_v1test', + eventType: 'run_completed', + createdAt: COMPLETED_TIME, + specVersion: 2, + eventData: { output: { result: 'ok' } }, + } as Event; + const events = [...stepEvents, runCompletedEvent]; + const trace = buildTrace(run, events, new Date()); + const { map } = parseTrace(trace); + + const runNode = map[run.runId]; + const result = computeSegments('run', runNode); + + const lastSegment = result.segments[result.segments.length - 1]; + expect(lastSegment.status).toBe('succeeded'); + }); + }); +}); From 83a3f081fc077bffd7ce1def91e0ce91fc011984 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 16 Mar 2026 12:57:59 -0700 Subject: [PATCH 4/8] add a test for web-shared --- packages/web-shared/package.json | 4 +++- .../{src/lib => test}/trace-builder-v1.test.ts | 13 ++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) rename packages/web-shared/{src/lib => test}/trace-builder-v1.test.ts (95%) diff --git a/packages/web-shared/package.json b/packages/web-shared/package.json index 603654f02d..e339f3d198 100644 --- a/packages/web-shared/package.json +++ b/packages/web-shared/package.json @@ -41,6 +41,7 @@ "build": "tsc && cp -r src/components/trace-viewer/*.css dist/components/trace-viewer/ && cp src/styles.css dist/styles.css", "dev": "tsc --watch", "clean": "tsc --build --clean && rm -r dist ||:", + "test": "vitest run", "typecheck": "tsc --noEmit", "lint": "biome check", "format": "biome format --write" @@ -71,6 +72,7 @@ "@types/react-dom": "19", "@workflow/tsconfig": "workspace:*", "ai": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/web-shared/src/lib/trace-builder-v1.test.ts b/packages/web-shared/test/trace-builder-v1.test.ts similarity index 95% rename from packages/web-shared/src/lib/trace-builder-v1.test.ts rename to packages/web-shared/test/trace-builder-v1.test.ts index b1d48b09a5..b1ffc4df65 100644 --- a/packages/web-shared/src/lib/trace-builder-v1.test.ts +++ b/packages/web-shared/test/trace-builder-v1.test.ts @@ -1,8 +1,11 @@ import type { Event, WorkflowRun } from '@workflow/world'; import { describe, expect, it } from 'vitest'; -import { computeSegments } from '../components/trace-viewer/components/span-segments.js'; -import { parseTrace } from '../components/trace-viewer/util/tree.js'; -import { buildTrace, groupEventsByCorrelation } from './trace-builder.js'; +import { computeSegments } from '../src/components/trace-viewer/components/span-segments.js'; +import { parseTrace } from '../src/components/trace-viewer/util/tree.js'; +import { + buildTrace, + groupEventsByCorrelation, +} from '../src/lib/trace-builder.js'; const BASE_TIME = new Date('2026-03-16T00:00:00Z'); const STARTED_TIME = new Date('2026-03-16T00:00:01Z'); @@ -100,7 +103,7 @@ describe('Trace viewer with v1 events (no run lifecycle events)', () => { const run = makeV1Run({ status: 'failed', output: undefined, - error: { message: 'boom', name: 'Error' }, + error: { message: 'boom' }, }); const events = makeStepEvents('step_1', 'add', 1000, 3000); const trace = buildTrace(run, events, new Date()); @@ -141,7 +144,7 @@ describe('Trace viewer with v1 events (no run lifecycle events)', () => { const run = makeV1Run({ status: 'failed', output: undefined, - error: { message: 'boom', name: 'Error' }, + error: { message: 'boom' }, }); const events = makeStepEvents('step_1', 'add', 1000, 3000); const trace = buildTrace(run, events, new Date()); From 76c1df6fd83ab58582fc2920c859bc14bc823f4a Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 16 Mar 2026 13:01:44 -0700 Subject: [PATCH 5/8] add lock file --- pnpm-lock.yaml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c292e663c..7fd5481143 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1213,6 +1213,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) packages/workflow: dependencies: @@ -23447,6 +23450,14 @@ snapshots: optionalDependencies: vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/mocker@4.0.18(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/mocker@4.0.18(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.18 @@ -32838,7 +32849,7 @@ snapshots: vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.1.12(@types/node@24.6.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.18(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 From 2859865d33afc3f587f21ff245f3bc902520c0b6 Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 16 Mar 2026 13:07:40 -0700 Subject: [PATCH 6/8] update logic to look for run_created event first --- .../trace-viewer/components/span-segments.ts | 69 ++++++++++++++++--- .../web-shared/test/trace-builder-v1.test.ts | 41 ++++++++++- 2 files changed, 99 insertions(+), 11 deletions(-) diff --git a/packages/web-shared/src/components/trace-viewer/components/span-segments.ts b/packages/web-shared/src/components/trace-viewer/components/span-segments.ts index d205091b8e..80293772fa 100644 --- a/packages/web-shared/src/components/trace-viewer/components/span-segments.ts +++ b/packages/web-shared/src/components/trace-viewer/components/span-segments.ts @@ -341,17 +341,29 @@ function computeRunSegments(node: SpanNode): Segment[] { if (duration <= 0) return segments; + // V1 runs (specVersion 1) don't emit run lifecycle events (run_created, + // run_started, run_completed). Detect this by checking for the absence of + // run_created — it's always the first event for v2 runs and is always + // loaded in the first page, so its absence reliably signals a v1 run. + // For v1, fall back to the run entity's status from span.attributes.data. + const hasRunCreated = events.some((e) => e.event.name === 'run_created'); + + if (!hasRunCreated) { + const runData = node.span.attributes?.data as + | Record + | undefined; + const runStatus = runData?.status as string | undefined; + return computeV1RunSegments( + startTime, + duration, + activeStartTime, + runStatus + ); + } + const failedEvent = events.find((e) => e.event.name === 'run_failed'); const completedEvent = events.find((e) => e.event.name === 'run_completed'); - // For v1 runs (specVersion 1), run lifecycle events (run_created, - // run_started, run_completed) are not present in the event log. Fall back - // to the run entity's status stored in span.attributes.data. - const runData = node.span.attributes?.data as - | Record - | undefined; - const runStatus = runData?.status as string | undefined; - // Queued period (from span start to activeStartTime) let cursor = 0; if (activeStartTime && activeStartTime > startTime) { @@ -403,7 +415,45 @@ function computeRunSegments(node: SpanNode): Segment[] { endFraction: 1, status: 'succeeded', }); - } else if (runStatus === 'failed') { + } else { + // Running to completion (or terminal events haven't loaded yet) + segments.push({ + startFraction: cursor, + endFraction: 1, + status: 'running', + }); + } + + return segments; +} + +/** + * Compute segments for a v1 run using only the run entity's status and + * activeStartTime (derived from run.startedAt). V1 runs have no run + * lifecycle events, so we infer the visual segments from entity fields. + */ +function computeV1RunSegments( + startTime: number, + duration: number, + activeStartTime: number | undefined, + runStatus: string | undefined +): Segment[] { + const segments: Segment[] = []; + + let cursor = 0; + if (activeStartTime && activeStartTime > startTime) { + const queuedFraction = timeToFraction(activeStartTime, startTime, duration); + if (queuedFraction > 0.001) { + segments.push({ + startFraction: 0, + endFraction: queuedFraction, + status: 'queued', + }); + cursor = queuedFraction; + } + } + + if (runStatus === 'failed') { segments.push({ startFraction: cursor, endFraction: 1, @@ -416,7 +466,6 @@ function computeRunSegments(node: SpanNode): Segment[] { status: 'succeeded', }); } else { - // Running to completion segments.push({ startFraction: cursor, endFraction: 1, diff --git a/packages/web-shared/test/trace-builder-v1.test.ts b/packages/web-shared/test/trace-builder-v1.test.ts index b1ffc4df65..6165d94576 100644 --- a/packages/web-shared/test/trace-builder-v1.test.ts +++ b/packages/web-shared/test/trace-builder-v1.test.ts @@ -192,6 +192,18 @@ describe('Trace viewer with v1 events (no run lifecycle events)', () => { it('v2 baseline: shows "succeeded" from run_completed event', () => { const run = makeV1Run({ specVersion: 2, status: 'completed' }); const stepEvents = makeStepEvents('step_1', 'add', 1000, 3000); + const runCreatedEvent: Event = { + eventId: 'evnt_run_created', + runId: 'wrun_v1test', + eventType: 'run_created', + createdAt: BASE_TIME, + specVersion: 2, + eventData: { + deploymentId: 'dep_1', + workflowName: 'v1-workflow', + input: {}, + }, + } as Event; const runCompletedEvent: Event = { eventId: 'evnt_run_completed', runId: 'wrun_v1test', @@ -200,7 +212,7 @@ describe('Trace viewer with v1 events (no run lifecycle events)', () => { specVersion: 2, eventData: { output: { result: 'ok' } }, } as Event; - const events = [...stepEvents, runCompletedEvent]; + const events = [runCreatedEvent, ...stepEvents, runCompletedEvent]; const trace = buildTrace(run, events, new Date()); const { map } = parseTrace(trace); @@ -210,5 +222,32 @@ describe('Trace viewer with v1 events (no run lifecycle events)', () => { const lastSegment = result.segments[result.segments.length - 1]; expect(lastSegment.status).toBe('succeeded'); }); + + it('v2 mid-pagination: shows "running" when run_completed has not loaded yet', () => { + const run = makeV1Run({ specVersion: 2, status: 'completed' }); + const stepEvents = makeStepEvents('step_1', 'add', 1000, 3000); + const runCreatedEvent: Event = { + eventId: 'evnt_run_created', + runId: 'wrun_v1test', + eventType: 'run_created', + createdAt: BASE_TIME, + specVersion: 2, + eventData: { + deploymentId: 'dep_1', + workflowName: 'v1-workflow', + input: {}, + }, + } as Event; + // run_created is present but run_completed hasn't loaded yet + const events = [runCreatedEvent, ...stepEvents]; + const trace = buildTrace(run, events, new Date()); + const { map } = parseTrace(trace); + + const runNode = map[run.runId]; + const result = computeSegments('run', runNode); + + const lastSegment = result.segments[result.segments.length - 1]; + expect(lastSegment.status).toBe('running'); + }); }); }); From ac3408170abee2a1ea0a713f413c9d2334f3f10b Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 16 Mar 2026 13:47:49 -0700 Subject: [PATCH 7/8] handle step created --- .../trace-span-construction.ts | 19 +++-- .../web-shared/test/trace-builder-v1.test.ts | 72 +++++++++++++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts b/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts index 624447f52a..235a73ec23 100644 --- a/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts +++ b/packages/web-shared/src/components/workflow-traces/trace-span-construction.ts @@ -129,9 +129,14 @@ export const stepEventsToStepEntity = ( const createdEvent = events.find( (event) => event.eventType === 'step_created' ); - if (!createdEvent) { + + // V1 runs don't emit step_created events. Fall back to the earliest event + // in the group so we can still build a step span. + const anchorEvent = createdEvent ?? events[0]; + if (!anchorEvent) { return null; } + // Walk events in order to derive status, attempt count, and timestamps. // Handles both step_retrying and consecutive step_started as retry signals. let status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' = @@ -168,16 +173,16 @@ export const stepEventsToStepEntity = ( const lastEvent = events[events.length - 1]; return { - stepId: createdEvent.correlationId, - runId: createdEvent.runId, - stepName: createdEvent.eventData?.stepName ?? '', + stepId: anchorEvent.correlationId ?? '', + runId: anchorEvent.runId, + stepName: createdEvent?.eventData?.stepName ?? '', status, attempt, - createdAt: createdEvent.createdAt, - updatedAt: lastEvent?.createdAt ?? createdEvent.createdAt, + createdAt: anchorEvent.createdAt, + updatedAt: lastEvent?.createdAt ?? anchorEvent.createdAt, startedAt, completedAt, - specVersion: createdEvent.specVersion, + specVersion: anchorEvent.specVersion, }; }; diff --git a/packages/web-shared/test/trace-builder-v1.test.ts b/packages/web-shared/test/trace-builder-v1.test.ts index 6165d94576..1109b9b2f1 100644 --- a/packages/web-shared/test/trace-builder-v1.test.ts +++ b/packages/web-shared/test/trace-builder-v1.test.ts @@ -31,6 +31,7 @@ function makeV1Run(overrides: Partial = {}): WorkflowRun { } as WorkflowRun; } +/** V2-style step events (includes step_created) */ function makeStepEvents( correlationId: string, stepName: string, @@ -67,6 +68,33 @@ function makeStepEvents( ] as Event[]; } +/** V1-style step events (no step_created — only step_started + step_completed) */ +function makeV1StepEvents( + correlationId: string, + startOffset: number, + endOffset: number +): Event[] { + return [ + { + eventId: `evnt_${correlationId}_started`, + runId: 'wrun_v1test', + eventType: 'step_started', + correlationId, + createdAt: new Date(BASE_TIME.getTime() + startOffset), + specVersion: 1, + }, + { + eventId: `evnt_${correlationId}_completed`, + runId: 'wrun_v1test', + eventType: 'step_completed', + correlationId, + createdAt: new Date(BASE_TIME.getTime() + endOffset), + specVersion: 1, + eventData: { result: 42 }, + }, + ] as Event[]; +} + describe('Trace viewer with v1 events (no run lifecycle events)', () => { describe('groupEventsByCorrelation', () => { it('groups step events with no run-level events for v1', () => { @@ -120,6 +148,50 @@ describe('Trace viewer with v1 events (no run lifecycle events)', () => { expect(trace.spans[0].spanId).toBe('wrun_v1test'); expect(trace.spans[0].attributes.resource).toBe('run'); }); + + it('builds step spans from v1 events (no step_created)', () => { + const run = makeV1Run({ status: 'completed' }); + const events = [ + ...makeV1StepEvents('step_1', 1000, 3000), + ...makeV1StepEvents('step_2', 4000, 6000), + ]; + const trace = buildTrace(run, events, new Date()); + + // Run span + 2 step spans + expect(trace.spans).toHaveLength(3); + + const stepSpans = trace.spans.filter( + (s) => s.attributes.resource === 'step' + ); + expect(stepSpans).toHaveLength(2); + expect(stepSpans[0].spanId).toBe('step_1'); + expect(stepSpans[1].spanId).toBe('step_2'); + }); + + it('derives step status from v1 events without step_created', () => { + const run = makeV1Run({ status: 'completed' }); + const events = makeV1StepEvents('step_1', 1000, 3000); + const trace = buildTrace(run, events, new Date()); + + const stepSpan = trace.spans.find((s) => s.spanId === 'step_1'); + expect(stepSpan).toBeDefined(); + expect(stepSpan!.attributes.data).toMatchObject({ + status: 'completed', + stepName: '', + }); + }); + + it('uses correlationId for step span when stepName is unavailable', () => { + const run = makeV1Run({ status: 'completed' }); + const events = makeV1StepEvents('step_1', 1000, 3000); + const trace = buildTrace(run, events, new Date()); + + const stepSpan = trace.spans.find((s) => s.spanId === 'step_1'); + expect(stepSpan).toBeDefined(); + // Without step_created, stepName is empty; the span name comes from + // parseStepName which returns the correlationId as fallback + expect(stepSpan!.spanId).toBe('step_1'); + }); }); describe('computeSegments for v1 run spans', () => { From b5f055d4d683e9596e49b5e0a57ddba24f7ec8ec Mon Sep 17 00:00:00 2001 From: Karthik Kalyanaraman Date: Mon, 16 Mar 2026 13:51:25 -0700 Subject: [PATCH 8/8] add changeset --- .changeset/seven-heads-retire.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/seven-heads-retire.md diff --git a/.changeset/seven-heads-retire.md b/.changeset/seven-heads-retire.md new file mode 100644 index 0000000000..722a78f4db --- /dev/null +++ b/.changeset/seven-heads-retire.md @@ -0,0 +1,5 @@ +--- +"@workflow/web-shared": patch +--- + +Fix trace construction for v1 runs