@workflow/web-shared 4.1.0-beta.61 → 4.1.0-beta.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/components/event-list-view.d.ts +2 -3
  2. package/dist/components/event-list-view.d.ts.map +1 -1
  3. package/dist/components/event-list-view.js +13 -10
  4. package/dist/components/event-list-view.js.map +1 -1
  5. package/dist/components/run-trace-view.d.ts +1 -3
  6. package/dist/components/run-trace-view.d.ts.map +1 -1
  7. package/dist/components/run-trace-view.js +2 -2
  8. package/dist/components/run-trace-view.js.map +1 -1
  9. package/dist/components/sidebar/attribute-panel.d.ts.map +1 -1
  10. package/dist/components/sidebar/attribute-panel.js +11 -1
  11. package/dist/components/sidebar/attribute-panel.js.map +1 -1
  12. package/dist/components/sidebar/detail-card.d.ts.map +1 -1
  13. package/dist/components/sidebar/detail-card.js +4 -2
  14. package/dist/components/sidebar/detail-card.js.map +1 -1
  15. package/dist/components/sidebar/entity-detail-panel.d.ts +3 -3
  16. package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
  17. package/dist/components/sidebar/entity-detail-panel.js +43 -26
  18. package/dist/components/sidebar/entity-detail-panel.js.map +1 -1
  19. package/dist/components/trace-viewer/trace-viewer.d.ts +7 -1
  20. package/dist/components/trace-viewer/trace-viewer.d.ts.map +1 -1
  21. package/dist/components/trace-viewer/trace-viewer.js +36 -11
  22. package/dist/components/trace-viewer/trace-viewer.js.map +1 -1
  23. package/dist/components/workflow-trace-view.d.ts +3 -3
  24. package/dist/components/workflow-trace-view.d.ts.map +1 -1
  25. package/dist/components/workflow-trace-view.js +31 -129
  26. package/dist/components/workflow-trace-view.js.map +1 -1
  27. package/dist/components/workflow-traces/trace-span-construction.d.ts +18 -5
  28. package/dist/components/workflow-traces/trace-span-construction.d.ts.map +1 -1
  29. package/dist/components/workflow-traces/trace-span-construction.js +65 -18
  30. package/dist/components/workflow-traces/trace-span-construction.js.map +1 -1
  31. package/dist/index.d.ts +3 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/lib/event-materialization.d.ts +72 -0
  36. package/dist/lib/event-materialization.d.ts.map +1 -0
  37. package/dist/lib/event-materialization.js +171 -0
  38. package/dist/lib/event-materialization.js.map +1 -0
  39. package/dist/lib/trace-builder.d.ts +32 -0
  40. package/dist/lib/trace-builder.d.ts.map +1 -0
  41. package/dist/lib/trace-builder.js +129 -0
  42. package/dist/lib/trace-builder.js.map +1 -0
  43. package/package.json +3 -3
  44. package/src/components/event-list-view.tsx +17 -13
  45. package/src/components/run-trace-view.tsx +0 -6
  46. package/src/components/sidebar/attribute-panel.tsx +17 -2
  47. package/src/components/sidebar/detail-card.tsx +10 -2
  48. package/src/components/sidebar/entity-detail-panel.tsx +59 -21
  49. package/src/components/trace-viewer/trace-viewer.tsx +47 -2
  50. package/src/components/workflow-trace-view.tsx +89 -195
  51. package/src/components/workflow-traces/trace-span-construction.ts +85 -32
  52. package/src/index.ts +13 -0
  53. package/src/lib/event-materialization.ts +243 -0
  54. package/src/lib/trace-builder.ts +201 -0
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { parseStepName, parseWorkflowName } from '@workflow/utils/parse-name';
6
- import type { Event, Step, WorkflowRun } from '@workflow/world';
6
+ import type { Event, WorkflowRun } from '@workflow/world';
7
7
  import type { Span, SpanEvent } from '../trace-viewer/types';
8
8
  import { shouldShowVerticalLine } from './event-colors';
9
9
  import { calculateDuration, dateToOtelTime } from './trace-time-utils';
@@ -81,18 +81,13 @@ export const waitEventsToWaitEntity = (
81
81
  /**
82
82
  * Converts a workflow Wait to an OpenTelemetry Span
83
83
  */
84
- export function waitToSpan(
85
- events: Event[],
86
- run: WorkflowRun,
87
- nowTime: Date
88
- ): Span | null {
84
+ export function waitToSpan(events: Event[], maxEndTime: Date): Span | null {
89
85
  const wait = waitEventsToWaitEntity(events);
90
86
  if (!wait) {
91
87
  return null;
92
88
  }
93
- const viewerEndTime = new Date(run.completedAt || nowTime) ?? nowTime;
94
- const startTime = wait?.createdAt ?? nowTime;
95
- const endTime = wait?.completedAt ?? viewerEndTime;
89
+ const startTime = wait.createdAt;
90
+ const endTime = wait.completedAt ?? maxEndTime;
96
91
  const start = dateToOtelTime(startTime);
97
92
  const end = dateToOtelTime(endTime);
98
93
  const duration = calculateDuration(startTime, endTime);
@@ -117,29 +112,92 @@ export function waitToSpan(
117
112
  };
118
113
  }
119
114
 
115
+ export const stepEventsToStepEntity = (
116
+ events: Event[]
117
+ ): {
118
+ stepId: string;
119
+ runId: string;
120
+ stepName: string;
121
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
122
+ attempt: number;
123
+ createdAt: Date;
124
+ updatedAt: Date;
125
+ startedAt?: Date;
126
+ completedAt?: Date;
127
+ specVersion?: number;
128
+ } | null => {
129
+ const createdEvent = events.find(
130
+ (event) => event.eventType === 'step_created'
131
+ );
132
+ if (!createdEvent) {
133
+ return null;
134
+ }
135
+ // Walk events in order to derive status, attempt count, and timestamps.
136
+ // Handles both step_retrying and consecutive step_started as retry signals.
137
+ let status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' =
138
+ 'pending';
139
+ let attempt = 0;
140
+ let startedAt: Date | undefined;
141
+ let completedAt: Date | undefined;
142
+
143
+ for (const e of events) {
144
+ switch (e.eventType) {
145
+ case 'step_started':
146
+ status = 'running';
147
+ attempt += 1;
148
+ if (!startedAt) startedAt = e.createdAt;
149
+ completedAt = undefined;
150
+ break;
151
+ case 'step_completed':
152
+ status = 'completed';
153
+ completedAt = e.createdAt;
154
+ break;
155
+ case 'step_failed':
156
+ status = 'failed';
157
+ completedAt = e.createdAt;
158
+ break;
159
+ case 'step_retrying':
160
+ status = 'pending';
161
+ completedAt = undefined;
162
+ break;
163
+ }
164
+ }
165
+
166
+ // Ensure at least attempt 1 if we never saw step_started
167
+ if (attempt === 0) attempt = 1;
168
+
169
+ const lastEvent = events[events.length - 1];
170
+ return {
171
+ stepId: createdEvent.correlationId,
172
+ runId: createdEvent.runId,
173
+ stepName: createdEvent.eventData?.stepName ?? '',
174
+ status,
175
+ attempt,
176
+ createdAt: createdEvent.createdAt,
177
+ updatedAt: lastEvent?.createdAt ?? createdEvent.createdAt,
178
+ startedAt,
179
+ completedAt,
180
+ specVersion: createdEvent.specVersion,
181
+ };
182
+ };
183
+
120
184
  /**
121
- * Converts a workflow Step to an OpenTelemetry Span
185
+ * Converts step events to an OpenTelemetry Span
122
186
  */
123
- export function stepToSpan(
124
- step: Step,
125
- stepEvents: Event[],
126
- nowTime?: Date,
127
- maxEndTime?: Date
128
- ): Span {
129
- const now = nowTime ?? new Date();
187
+ export function stepToSpan(stepEvents: Event[], maxEndTime: Date): Span | null {
188
+ const step = stepEventsToStepEntity(stepEvents);
189
+ if (!step) {
190
+ return null;
191
+ }
130
192
  const parsedName = parseStepName(String(step.stepName));
131
193
 
132
- // Only embed identification fields — not the full object with
133
- // input/output/error which may contain non-cloneable types.
134
- // The detail panel fetches full data separately via spanDetailData.
135
- const { input: _i, output: _o, error: _e, ...stepIdentity } = step;
136
194
  const attributes = {
137
195
  resource: 'step' as const,
138
- data: stepIdentity,
196
+ data: step,
139
197
  };
140
198
 
141
199
  const resource = 'step';
142
- const endTime = new Date(step.completedAt ?? maxEndTime ?? now);
200
+ const endTime = new Date(step.completedAt ?? maxEndTime);
143
201
 
144
202
  // Include ALL correlated events on the span so the sidebar detail view
145
203
  // can display them. The timeline uses the `showVerticalLine` flag to
@@ -187,6 +245,7 @@ export const hookEventsToHookEntity = (
187
245
  ): {
188
246
  hookId: string;
189
247
  runId: string;
248
+ token?: string;
190
249
  createdAt: Date;
191
250
  receivedCount: number;
192
251
  lastReceivedAt?: Date;
@@ -208,6 +267,7 @@ export const hookEventsToHookEntity = (
208
267
  return {
209
268
  hookId: createdEvent.correlationId,
210
269
  runId: createdEvent.runId,
270
+ token: createdEvent.eventData?.token,
211
271
  createdAt: createdEvent.createdAt,
212
272
  receivedCount: receivedEvents.length,
213
273
  lastReceivedAt: lastReceivedEvent?.createdAt || undefined,
@@ -218,11 +278,7 @@ export const hookEventsToHookEntity = (
218
278
  /**
219
279
  * Converts a workflow Hook to an OpenTelemetry Span
220
280
  */
221
- export function hookToSpan(
222
- hookEvents: Event[],
223
- run: WorkflowRun,
224
- nowTime: Date
225
- ): Span | null {
281
+ export function hookToSpan(hookEvents: Event[], maxEndTime: Date): Span | null {
226
282
  const hook = hookEventsToHookEntity(hookEvents);
227
283
  if (!hook) {
228
284
  return null;
@@ -231,10 +287,7 @@ export function hookToSpan(
231
287
  // Convert hook-related events to span events
232
288
  const events = convertEventsToSpanEvents(hookEvents, false);
233
289
 
234
- // We display hooks as a minimum span size of 10 seconds, just to ensure
235
- // it's clickable even if there is no
236
- const viewerEndTime = new Date(run.completedAt || nowTime) ?? nowTime;
237
- const endTime = hook.disposedAt || viewerEndTime;
290
+ const endTime = hook.disposedAt || maxEndTime;
238
291
 
239
292
  return {
240
293
  spanId: String(hook.hookId),
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
10
10
  export * from './components';
11
11
  export {
12
12
  hookEventsToHookEntity,
13
+ stepEventsToStepEntity,
13
14
  waitEventsToWaitEntity,
14
15
  } from './components/workflow-traces/trace-span-construction';
15
16
  export type { EventAnalysis } from './lib/event-analysis';
@@ -20,6 +21,18 @@ export {
20
21
  isTerminalStatus,
21
22
  shouldShowReenqueueButton,
22
23
  } from './lib/event-analysis';
24
+ export type {
25
+ MaterializedEntities,
26
+ MaterializedHook,
27
+ MaterializedStep,
28
+ MaterializedWait,
29
+ } from './lib/event-materialization';
30
+ export {
31
+ materializeAll,
32
+ materializeHooks,
33
+ materializeSteps,
34
+ materializeWaits,
35
+ } from './lib/event-materialization';
23
36
  export type { Revivers, StreamRef } from './lib/hydration';
24
37
  export {
25
38
  CLASS_INSTANCE_REF_TYPE,
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Event materialization helpers.
3
+ *
4
+ * These functions convert a flat list of workflow events into entity-like
5
+ * objects (steps, hooks, waits) by grouping events by correlationId and
6
+ * stitching together lifecycle events.
7
+ *
8
+ * This enables a "top-down" data fetching pattern where the client fetches
9
+ * all events for a run once, then materializes entities client-side instead
10
+ * of making separate API calls for each entity type.
11
+ */
12
+
13
+ import type { Event, StepStatus } from '@workflow/world';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Materialized entity types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface MaterializedStep {
20
+ stepId: string;
21
+ runId: string;
22
+ stepName: string;
23
+ status: StepStatus;
24
+ attempt: number;
25
+ createdAt: Date;
26
+ startedAt?: Date;
27
+ completedAt?: Date;
28
+ updatedAt: Date;
29
+ /** All events for this step, in insertion order */
30
+ events: Event[];
31
+ }
32
+
33
+ export interface MaterializedHook {
34
+ hookId: string;
35
+ runId: string;
36
+ token?: string;
37
+ createdAt: Date;
38
+ receivedCount: number;
39
+ lastReceivedAt?: Date;
40
+ disposedAt?: Date;
41
+ /** All events for this hook, in insertion order */
42
+ events: Event[];
43
+ }
44
+
45
+ export interface MaterializedWait {
46
+ waitId: string;
47
+ runId: string;
48
+ status: 'waiting' | 'completed';
49
+ createdAt: Date;
50
+ resumeAt?: Date;
51
+ completedAt?: Date;
52
+ /** All events for this wait, in insertion order */
53
+ events: Event[];
54
+ }
55
+
56
+ export interface MaterializedEntities {
57
+ steps: MaterializedStep[];
58
+ hooks: MaterializedHook[];
59
+ waits: MaterializedWait[];
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Helper: group events by correlationId prefix
64
+ // ---------------------------------------------------------------------------
65
+
66
+ function groupByCorrelationId(
67
+ events: Event[],
68
+ prefixes: string[]
69
+ ): Map<string, Event[]> {
70
+ const groups = new Map<string, Event[]>();
71
+ for (const event of events) {
72
+ const cid = event.correlationId;
73
+ if (!cid) continue;
74
+ if (!prefixes.some((p) => cid.startsWith(p))) continue;
75
+ const existing = groups.get(cid);
76
+ if (existing) {
77
+ existing.push(event);
78
+ } else {
79
+ groups.set(cid, [event]);
80
+ }
81
+ }
82
+ return groups;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // materializeSteps
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Group step_* events by correlationId and build Step-like entities.
91
+ *
92
+ * Handles partial event lists gracefully: a step may only have a
93
+ * step_created event with no completion yet.
94
+ */
95
+ export function materializeSteps(events: Event[]): MaterializedStep[] {
96
+ const groups = groupByCorrelationId(events, ['step_']);
97
+ const steps: MaterializedStep[] = [];
98
+
99
+ for (const [correlationId, stepEvents] of groups) {
100
+ const created = stepEvents.find((e) => e.eventType === 'step_created');
101
+ if (!created) continue;
102
+
103
+ let status: StepStatus = 'pending';
104
+ let attempt = 0;
105
+ let startedAt: Date | undefined;
106
+ let completedAt: Date | undefined;
107
+ let updatedAt = created.createdAt;
108
+
109
+ for (const e of stepEvents) {
110
+ switch (e.eventType) {
111
+ case 'step_started':
112
+ status = 'running';
113
+ attempt += 1;
114
+ if (!startedAt) startedAt = e.createdAt;
115
+ completedAt = undefined;
116
+ updatedAt = e.createdAt;
117
+ break;
118
+ case 'step_completed':
119
+ status = 'completed';
120
+ completedAt = e.createdAt;
121
+ updatedAt = e.createdAt;
122
+ break;
123
+ case 'step_failed':
124
+ status = 'failed';
125
+ completedAt = e.createdAt;
126
+ updatedAt = e.createdAt;
127
+ break;
128
+ case 'step_retrying':
129
+ status = 'pending';
130
+ completedAt = undefined;
131
+ updatedAt = e.createdAt;
132
+ break;
133
+ }
134
+ }
135
+
136
+ steps.push({
137
+ stepId: correlationId,
138
+ runId: created.runId,
139
+ stepName:
140
+ created.eventType === 'step_created'
141
+ ? (created.eventData?.stepName ?? correlationId)
142
+ : correlationId,
143
+ status,
144
+ attempt,
145
+ createdAt: created.createdAt,
146
+ startedAt,
147
+ completedAt,
148
+ updatedAt,
149
+ events: stepEvents,
150
+ });
151
+ }
152
+
153
+ return steps;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // materializeHooks
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Group hook_* events by correlationId and build Hook-like entities.
162
+ */
163
+ export function materializeHooks(events: Event[]): MaterializedHook[] {
164
+ const groups = groupByCorrelationId(events, ['hook_']);
165
+ const hooks: MaterializedHook[] = [];
166
+
167
+ for (const [correlationId, hookEvents] of groups) {
168
+ const created = hookEvents.find((e) => e.eventType === 'hook_created');
169
+ if (!created) continue;
170
+
171
+ const receivedEvents = hookEvents.filter(
172
+ (e) => e.eventType === 'hook_received'
173
+ );
174
+ const disposed = hookEvents.find((e) => e.eventType === 'hook_disposed');
175
+ const lastReceived = receivedEvents.at(-1);
176
+
177
+ hooks.push({
178
+ hookId: correlationId,
179
+ runId: created.runId,
180
+ token:
181
+ created.eventType === 'hook_created'
182
+ ? created.eventData?.token
183
+ : undefined,
184
+ createdAt: created.createdAt,
185
+ receivedCount: receivedEvents.length,
186
+ lastReceivedAt: lastReceived?.createdAt,
187
+ disposedAt: disposed?.createdAt,
188
+ events: hookEvents,
189
+ });
190
+ }
191
+
192
+ return hooks;
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // materializeWaits
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /**
200
+ * Group wait_* events by correlationId and build Wait-like entities.
201
+ */
202
+ export function materializeWaits(events: Event[]): MaterializedWait[] {
203
+ const groups = groupByCorrelationId(events, ['wait_']);
204
+ const waits: MaterializedWait[] = [];
205
+
206
+ for (const [correlationId, waitEvents] of groups) {
207
+ const created = waitEvents.find((e) => e.eventType === 'wait_created');
208
+ if (!created) continue;
209
+
210
+ const completed = waitEvents.find((e) => e.eventType === 'wait_completed');
211
+
212
+ waits.push({
213
+ waitId: correlationId,
214
+ runId: created.runId,
215
+ status: completed ? 'completed' : 'waiting',
216
+ createdAt: created.createdAt,
217
+ resumeAt:
218
+ created.eventType === 'wait_created'
219
+ ? created.eventData?.resumeAt
220
+ : undefined,
221
+ completedAt: completed?.createdAt,
222
+ events: waitEvents,
223
+ });
224
+ }
225
+
226
+ return waits;
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // materializeAll
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /**
234
+ * Convenience function that materializes all entity types from a flat
235
+ * event list.
236
+ */
237
+ export function materializeAll(events: Event[]): MaterializedEntities {
238
+ return {
239
+ steps: materializeSteps(events),
240
+ hooks: materializeHooks(events),
241
+ waits: materializeWaits(events),
242
+ };
243
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Builds a complete trace from a WorkflowRun and its Events.
3
+ *
4
+ * This module groups raw events by correlation ID and entity type,
5
+ * converts each group into an OpenTelemetry-style Span, and returns
6
+ * a fully-formed Trace ready for the trace viewer.
7
+ */
8
+
9
+ import type { Event, WorkflowRun } from '@workflow/world';
10
+ import type { Span } from '../components/trace-viewer/types';
11
+ import {
12
+ hookToSpan,
13
+ runToSpan,
14
+ stepToSpan,
15
+ waitToSpan,
16
+ WORKFLOW_LIBRARY,
17
+ } from '../components/workflow-traces/trace-span-construction';
18
+ import { otelTimeToMs } from '../components/workflow-traces/trace-time-utils';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Event type classifiers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export const isStepEvent = (eventType: string) => eventType.startsWith('step_');
25
+
26
+ export const isTimerEvent = (eventType: string) =>
27
+ eventType === 'wait_created' || eventType === 'wait_completed';
28
+
29
+ export const isHookLifecycleEvent = (eventType: string) =>
30
+ eventType === 'hook_received' ||
31
+ eventType === 'hook_created' ||
32
+ eventType === 'hook_disposed';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Event grouping
36
+ // ---------------------------------------------------------------------------
37
+
38
+ export type GroupedEvents = {
39
+ eventsByStepId: Map<string, Event[]>;
40
+ runLevelEvents: Event[];
41
+ timerEvents: Map<string, Event[]>;
42
+ hookEvents: Map<string, Event[]>;
43
+ };
44
+
45
+ function pushEvent(
46
+ map: Map<string, Event[]>,
47
+ correlationId: string,
48
+ event: Event
49
+ ) {
50
+ const existing = map.get(correlationId);
51
+ if (existing) {
52
+ existing.push(event);
53
+ return;
54
+ }
55
+ map.set(correlationId, [event]);
56
+ }
57
+
58
+ export function groupEventsByCorrelation(events: Event[]): GroupedEvents {
59
+ const eventsByStepId = new Map<string, Event[]>();
60
+ const runLevelEvents: Event[] = [];
61
+ const timerEvents = new Map<string, Event[]>();
62
+ const hookEvents = new Map<string, Event[]>();
63
+
64
+ for (const event of events) {
65
+ const correlationId = event.correlationId;
66
+ if (!correlationId) {
67
+ runLevelEvents.push(event);
68
+ continue;
69
+ }
70
+
71
+ if (isTimerEvent(event.eventType)) {
72
+ pushEvent(timerEvents, correlationId, event);
73
+ continue;
74
+ }
75
+
76
+ if (isHookLifecycleEvent(event.eventType)) {
77
+ pushEvent(hookEvents, correlationId, event);
78
+ continue;
79
+ }
80
+
81
+ if (isStepEvent(event.eventType)) {
82
+ pushEvent(eventsByStepId, correlationId, event);
83
+ continue;
84
+ }
85
+
86
+ runLevelEvents.push(event);
87
+ }
88
+
89
+ return { eventsByStepId, runLevelEvents, timerEvents, hookEvents };
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Trace construction
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Computes the latest known event time from the events list.
98
+ * Active (in-progress) child spans are capped at this time instead of `now`,
99
+ * so they only extend as far as our data goes.
100
+ */
101
+ function computeLatestKnownTime(events: Event[], run: WorkflowRun): Date {
102
+ let latest = new Date(run.createdAt).getTime();
103
+ for (const event of events) {
104
+ const t = new Date(event.createdAt).getTime();
105
+ if (t > latest) latest = t;
106
+ }
107
+ return new Date(latest);
108
+ }
109
+
110
+ function buildSpans(
111
+ run: WorkflowRun,
112
+ groupedEvents: GroupedEvents,
113
+ now: Date,
114
+ latestKnownTime: Date
115
+ ) {
116
+ // Active child spans cap at latestKnownTime so they don't extend into
117
+ // unknown territory. Even when the run is completed, we may not have loaded
118
+ // all events yet, so always use the latest event we've actually seen.
119
+ const childMaxEnd = latestKnownTime;
120
+
121
+ const stepSpans = Array.from(groupedEvents.eventsByStepId.values())
122
+ .map((events) => stepToSpan(events, childMaxEnd))
123
+ .filter((span): span is Span => span !== null);
124
+
125
+ const hookSpans = Array.from(groupedEvents.hookEvents.values())
126
+ .map((events) => hookToSpan(events, childMaxEnd))
127
+ .filter((span): span is Span => span !== null);
128
+
129
+ const waitSpans = Array.from(groupedEvents.timerEvents.values())
130
+ .map((events) => waitToSpan(events, childMaxEnd))
131
+ .filter((span): span is Span => span !== null);
132
+
133
+ return {
134
+ runSpan: runToSpan(run, groupedEvents.runLevelEvents, now),
135
+ spans: [...stepSpans, ...hookSpans, ...waitSpans],
136
+ };
137
+ }
138
+
139
+ function cascadeSpans(runSpan: Span, spans: Span[]) {
140
+ const sortedSpans = [
141
+ runSpan,
142
+ ...spans.slice().sort((a, b) => {
143
+ const aStart = otelTimeToMs(a.startTime);
144
+ const bStart = otelTimeToMs(b.startTime);
145
+ return aStart - bStart;
146
+ }),
147
+ ];
148
+
149
+ return sortedSpans.map((span, index) => {
150
+ const parentSpanId =
151
+ index === 0 ? undefined : String(sortedSpans[index - 1].spanId);
152
+ return {
153
+ ...span,
154
+ parentSpanId,
155
+ };
156
+ });
157
+ }
158
+
159
+ export interface TraceWithMeta {
160
+ traceId: string;
161
+ rootSpanId: string;
162
+ spans: Span[];
163
+ resources: { name: string; attributes: Record<string, string> }[];
164
+ /** Duration in ms from trace start to the latest known event. */
165
+ knownDurationMs: number;
166
+ }
167
+
168
+ export function buildTrace(
169
+ run: WorkflowRun,
170
+ events: Event[],
171
+ now: Date
172
+ ): TraceWithMeta {
173
+ const groupedEvents = groupEventsByCorrelation(events);
174
+ const latestKnownTime = computeLatestKnownTime(events, run);
175
+ const { runSpan, spans } = buildSpans(
176
+ run,
177
+ groupedEvents,
178
+ now,
179
+ latestKnownTime
180
+ );
181
+ const sortedCascadingSpans = cascadeSpans(runSpan, spans);
182
+
183
+ // Compute known duration relative to trace start
184
+ const traceStartMs = otelTimeToMs(runSpan.startTime);
185
+ const knownDurationMs = latestKnownTime.getTime() - traceStartMs;
186
+
187
+ return {
188
+ traceId: run.runId,
189
+ rootSpanId: run.runId,
190
+ spans: sortedCascadingSpans,
191
+ resources: [
192
+ {
193
+ name: 'workflow',
194
+ attributes: {
195
+ 'service.name': WORKFLOW_LIBRARY.name,
196
+ },
197
+ },
198
+ ],
199
+ knownDurationMs: Math.max(0, knownDurationMs),
200
+ };
201
+ }