live-traces 0.1.0
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.
- package/README.md +193 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/react/hooks.d.ts +22 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +68 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react/index.d.ts +26 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +27 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/store.d.ts +77 -0
- package/dist/react/store.d.ts.map +1 -0
- package/dist/react/store.js +273 -0
- package/dist/react/store.js.map +1 -0
- package/dist/src/LiveTrace.d.ts +55 -0
- package/dist/src/LiveTrace.d.ts.map +1 -0
- package/dist/src/LiveTrace.js +66 -0
- package/dist/src/LiveTrace.js.map +1 -0
- package/dist/src/Logger.d.ts +3 -0
- package/dist/src/Logger.d.ts.map +1 -0
- package/dist/src/Logger.js +30 -0
- package/dist/src/Logger.js.map +1 -0
- package/dist/src/Schema.d.ts +97 -0
- package/dist/src/Schema.d.ts.map +1 -0
- package/dist/src/Schema.js +60 -0
- package/dist/src/Schema.js.map +1 -0
- package/dist/src/Sink.d.ts +36 -0
- package/dist/src/Sink.d.ts.map +1 -0
- package/dist/src/Sink.js +55 -0
- package/dist/src/Sink.js.map +1 -0
- package/dist/src/Tracer.d.ts +24 -0
- package/dist/src/Tracer.d.ts.map +1 -0
- package/dist/src/Tracer.js +154 -0
- package/dist/src/Tracer.js.map +1 -0
- package/dist/src/WrappedSpan.d.ts +44 -0
- package/dist/src/WrappedSpan.d.ts.map +1 -0
- package/dist/src/WrappedSpan.js +104 -0
- package/dist/src/WrappedSpan.js.map +1 -0
- package/dist/src/transports/sse.d.ts +26 -0
- package/dist/src/transports/sse.d.ts.map +1 -0
- package/dist/src/transports/sse.js +118 -0
- package/dist/src/transports/sse.js.map +1 -0
- package/dist/src/types.d.ts +73 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +69 -0
- package/dist/src/types.js.map +1 -0
- package/index.ts +58 -0
- package/package.json +87 -0
- package/react/hooks.ts +73 -0
- package/react/index.ts +30 -0
- package/react/store.ts +357 -0
- package/src/LiveTrace.ts +99 -0
- package/src/Logger.ts +33 -0
- package/src/Schema.ts +70 -0
- package/src/Sink.ts +108 -0
- package/src/Tracer.ts +176 -0
- package/src/WrappedSpan.ts +138 -0
- package/src/__tests__/tracer.test.ts +238 -0
- package/src/transports/sse.ts +127 -0
- package/src/types.ts +151 -0
package/react/store.ts
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trace Store — Reactive store for live trace events.
|
|
3
|
+
*
|
|
4
|
+
* Reduces a stream of TraceEvents into a tree of SpanNodes per trace.
|
|
5
|
+
* Framework-agnostic core — uses useSyncExternalStore for React binding.
|
|
6
|
+
*
|
|
7
|
+
* No Effect dependency. Consumes plain TraceEvent JSON from any source.
|
|
8
|
+
*/
|
|
9
|
+
import type { TraceEvent, TraceStart, SpanStart, SpanEnd, SpanEvent, TraceEnd, TraceScope } from "../src/types.js";
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Types
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
export type TraceStatus = "running" | "completed" | "failed";
|
|
16
|
+
|
|
17
|
+
export interface SpanEventEntry {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly level?: string;
|
|
20
|
+
readonly attributes?: Record<string, unknown>;
|
|
21
|
+
readonly timestamp: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SpanNode {
|
|
25
|
+
readonly spanId: string;
|
|
26
|
+
readonly parentSpanId?: string;
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly status: "running" | "ok" | "error";
|
|
29
|
+
readonly attributes: Record<string, unknown>;
|
|
30
|
+
readonly events: SpanEventEntry[];
|
|
31
|
+
readonly children: SpanNode[];
|
|
32
|
+
readonly startedAt: number;
|
|
33
|
+
readonly durationMs?: number;
|
|
34
|
+
/** True if this span has ui.step attribute */
|
|
35
|
+
readonly isStep: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface TraceState {
|
|
39
|
+
readonly traceId: string;
|
|
40
|
+
readonly label: string;
|
|
41
|
+
readonly scope: TraceScope;
|
|
42
|
+
readonly status: TraceStatus;
|
|
43
|
+
readonly rootSpanId?: string;
|
|
44
|
+
readonly spans: Map<string, SpanNode>;
|
|
45
|
+
readonly startedAt: number;
|
|
46
|
+
readonly completedAt?: number;
|
|
47
|
+
readonly durationMs?: number;
|
|
48
|
+
readonly error?: string;
|
|
49
|
+
readonly updatedAt: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ============================================================================
|
|
53
|
+
// Store
|
|
54
|
+
// ============================================================================
|
|
55
|
+
|
|
56
|
+
/** TTL for completed traces before eviction (ms) */
|
|
57
|
+
const COMPLETED_TTL_MS = 30_000;
|
|
58
|
+
|
|
59
|
+
/** Maximum events per span */
|
|
60
|
+
const MAX_SPAN_EVENTS = 50;
|
|
61
|
+
|
|
62
|
+
/** Replay filter: skip events older than 30 minutes */
|
|
63
|
+
const REPLAY_MAX_AGE_MS = 30 * 60 * 1000;
|
|
64
|
+
|
|
65
|
+
type Listener = () => void;
|
|
66
|
+
|
|
67
|
+
export class TraceStore {
|
|
68
|
+
private traces = new Map<string, TraceState>();
|
|
69
|
+
private listeners = new Set<Listener>();
|
|
70
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
71
|
+
|
|
72
|
+
constructor() {
|
|
73
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), 5_000);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Subscribe to store changes (for useSyncExternalStore) */
|
|
77
|
+
subscribe = (listener: Listener): (() => void) => {
|
|
78
|
+
this.listeners.add(listener);
|
|
79
|
+
return () => this.listeners.delete(listener);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Get snapshot (for useSyncExternalStore) */
|
|
83
|
+
getSnapshot = (): Map<string, TraceState> => this.traces;
|
|
84
|
+
|
|
85
|
+
/** Dispatch a single trace event */
|
|
86
|
+
dispatch(event: TraceEvent): void {
|
|
87
|
+
// Skip ancient events on replay
|
|
88
|
+
if (Date.now() - event.timestamp > REPLAY_MAX_AGE_MS) return;
|
|
89
|
+
|
|
90
|
+
switch (event._tag) {
|
|
91
|
+
case "TraceStart":
|
|
92
|
+
this.handleTraceStart(event);
|
|
93
|
+
break;
|
|
94
|
+
case "SpanStart":
|
|
95
|
+
this.handleSpanStart(event);
|
|
96
|
+
break;
|
|
97
|
+
case "SpanEnd":
|
|
98
|
+
this.handleSpanEnd(event);
|
|
99
|
+
break;
|
|
100
|
+
case "SpanEvent":
|
|
101
|
+
this.handleSpanEvent(event);
|
|
102
|
+
break;
|
|
103
|
+
case "TraceEnd":
|
|
104
|
+
this.handleTraceEnd(event);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.notify();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Dispatch a batch of events */
|
|
112
|
+
dispatchBatch(events: TraceEvent[]): void {
|
|
113
|
+
for (const event of events) {
|
|
114
|
+
// Inline without notify per event
|
|
115
|
+
if (Date.now() - event.timestamp > REPLAY_MAX_AGE_MS) continue;
|
|
116
|
+
switch (event._tag) {
|
|
117
|
+
case "TraceStart":
|
|
118
|
+
this.handleTraceStart(event);
|
|
119
|
+
break;
|
|
120
|
+
case "SpanStart":
|
|
121
|
+
this.handleSpanStart(event);
|
|
122
|
+
break;
|
|
123
|
+
case "SpanEnd":
|
|
124
|
+
this.handleSpanEnd(event);
|
|
125
|
+
break;
|
|
126
|
+
case "SpanEvent":
|
|
127
|
+
this.handleSpanEvent(event);
|
|
128
|
+
break;
|
|
129
|
+
case "TraceEnd":
|
|
130
|
+
this.handleTraceEnd(event);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
this.notify();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Get a single trace */
|
|
138
|
+
getTrace(traceId: string): TraceState | undefined {
|
|
139
|
+
return this.traces.get(traceId);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Get all traces as array, sorted by startedAt descending */
|
|
143
|
+
getAllTraces(): TraceState[] {
|
|
144
|
+
return Array.from(this.traces.values()).toSorted((a, b) => b.startedAt - a.startedAt);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Get step spans (ui.step=true) for a trace, in order */
|
|
148
|
+
getSteps(traceId: string): SpanNode[] {
|
|
149
|
+
const trace = this.traces.get(traceId);
|
|
150
|
+
if (!trace) return [];
|
|
151
|
+
return Array.from(trace.spans.values())
|
|
152
|
+
.filter((s) => s.isStep)
|
|
153
|
+
.toSorted((a, b) => a.startedAt - b.startedAt);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Build the span tree for a trace */
|
|
157
|
+
getSpanTree(traceId: string): SpanNode | undefined {
|
|
158
|
+
const trace = this.traces.get(traceId);
|
|
159
|
+
if (!trace || !trace.rootSpanId) return undefined;
|
|
160
|
+
return trace.spans.get(trace.rootSpanId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Destroy the store (stop cleanup timer) */
|
|
164
|
+
destroy(): void {
|
|
165
|
+
if (this.cleanupTimer) {
|
|
166
|
+
clearInterval(this.cleanupTimer);
|
|
167
|
+
this.cleanupTimer = null;
|
|
168
|
+
}
|
|
169
|
+
this.listeners.clear();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// -- Event handlers --
|
|
173
|
+
|
|
174
|
+
private handleTraceStart(event: TraceStart): void {
|
|
175
|
+
const existing = this.traces.get(event.traceId);
|
|
176
|
+
if (existing) return; // idempotent
|
|
177
|
+
|
|
178
|
+
this.traces = new Map(this.traces);
|
|
179
|
+
this.traces.set(event.traceId, {
|
|
180
|
+
traceId: event.traceId,
|
|
181
|
+
label: event.label,
|
|
182
|
+
scope: event.scope,
|
|
183
|
+
status: "running",
|
|
184
|
+
spans: new Map(),
|
|
185
|
+
startedAt: event.timestamp,
|
|
186
|
+
updatedAt: event.timestamp,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private handleSpanStart(event: SpanStart): void {
|
|
191
|
+
const trace = this.traces.get(event.traceId);
|
|
192
|
+
if (!trace) return;
|
|
193
|
+
|
|
194
|
+
const span: SpanNode = {
|
|
195
|
+
spanId: event.spanId,
|
|
196
|
+
parentSpanId: event.parentSpanId,
|
|
197
|
+
name: event.name,
|
|
198
|
+
status: "running",
|
|
199
|
+
attributes: event.attributes,
|
|
200
|
+
events: [],
|
|
201
|
+
children: [],
|
|
202
|
+
startedAt: event.timestamp,
|
|
203
|
+
isStep: event.attributes["ui.step"] === true,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const newSpans = new Map(trace.spans);
|
|
207
|
+
newSpans.set(event.spanId, span);
|
|
208
|
+
|
|
209
|
+
// Add as child of parent span
|
|
210
|
+
if (event.parentSpanId) {
|
|
211
|
+
const parent = newSpans.get(event.parentSpanId);
|
|
212
|
+
if (parent) {
|
|
213
|
+
newSpans.set(event.parentSpanId, {
|
|
214
|
+
...parent,
|
|
215
|
+
children: [...parent.children, span],
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
this.traces = new Map(this.traces);
|
|
221
|
+
this.traces.set(event.traceId, {
|
|
222
|
+
...trace,
|
|
223
|
+
spans: newSpans,
|
|
224
|
+
rootSpanId: trace.rootSpanId ?? event.spanId,
|
|
225
|
+
updatedAt: event.timestamp,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private handleSpanEnd(event: SpanEnd): void {
|
|
230
|
+
const trace = this.traces.get(event.traceId);
|
|
231
|
+
if (!trace) return;
|
|
232
|
+
|
|
233
|
+
const span = trace.spans.get(event.spanId);
|
|
234
|
+
if (!span) return;
|
|
235
|
+
|
|
236
|
+
const newSpans = new Map(trace.spans);
|
|
237
|
+
const updatedSpan = {
|
|
238
|
+
...span,
|
|
239
|
+
status: event.status as "ok" | "error",
|
|
240
|
+
durationMs: event.durationMs,
|
|
241
|
+
};
|
|
242
|
+
newSpans.set(event.spanId, updatedSpan);
|
|
243
|
+
|
|
244
|
+
// Update in parent's children array too
|
|
245
|
+
if (span.parentSpanId) {
|
|
246
|
+
const parent = newSpans.get(span.parentSpanId);
|
|
247
|
+
if (parent) {
|
|
248
|
+
newSpans.set(span.parentSpanId, {
|
|
249
|
+
...parent,
|
|
250
|
+
children: parent.children.map((c) => (c.spanId === event.spanId ? updatedSpan : c)),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
this.traces = new Map(this.traces);
|
|
256
|
+
this.traces.set(event.traceId, {
|
|
257
|
+
...trace,
|
|
258
|
+
spans: newSpans,
|
|
259
|
+
updatedAt: event.timestamp,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private handleSpanEvent(event: SpanEvent): void {
|
|
264
|
+
const trace = this.traces.get(event.traceId);
|
|
265
|
+
if (!trace) return;
|
|
266
|
+
|
|
267
|
+
const span = trace.spans.get(event.spanId);
|
|
268
|
+
if (!span) return;
|
|
269
|
+
|
|
270
|
+
const entry: SpanEventEntry = {
|
|
271
|
+
name: event.name,
|
|
272
|
+
level: event.level,
|
|
273
|
+
attributes: event.attributes,
|
|
274
|
+
timestamp: event.timestamp,
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const newEvents = span.events.length >= MAX_SPAN_EVENTS ? [...span.events.slice(-MAX_SPAN_EVENTS + 1), entry] : [...span.events, entry];
|
|
278
|
+
|
|
279
|
+
const newSpans = new Map(trace.spans);
|
|
280
|
+
newSpans.set(event.spanId, { ...span, events: newEvents });
|
|
281
|
+
|
|
282
|
+
this.traces = new Map(this.traces);
|
|
283
|
+
this.traces.set(event.traceId, {
|
|
284
|
+
...trace,
|
|
285
|
+
spans: newSpans,
|
|
286
|
+
updatedAt: event.timestamp,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private handleTraceEnd(event: TraceEnd): void {
|
|
291
|
+
const trace = this.traces.get(event.traceId);
|
|
292
|
+
if (!trace) return;
|
|
293
|
+
|
|
294
|
+
// Close any still-running spans (handles lost SpanEnd events)
|
|
295
|
+
const finalStatus = event.status === "completed" ? ("ok" as const) : ("error" as const);
|
|
296
|
+
let newSpans = trace.spans;
|
|
297
|
+
let hasRunningSpans = false;
|
|
298
|
+
for (const span of trace.spans.values()) {
|
|
299
|
+
if (span.status === "running") {
|
|
300
|
+
hasRunningSpans = true;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (hasRunningSpans) {
|
|
305
|
+
newSpans = new Map(trace.spans);
|
|
306
|
+
for (const [id, span] of newSpans) {
|
|
307
|
+
if (span.status === "running") {
|
|
308
|
+
newSpans.set(id, { ...span, status: finalStatus, durationMs: event.timestamp - span.startedAt });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
this.traces = new Map(this.traces);
|
|
314
|
+
this.traces.set(event.traceId, {
|
|
315
|
+
...trace,
|
|
316
|
+
spans: newSpans,
|
|
317
|
+
status: event.status === "completed" ? "completed" : "failed",
|
|
318
|
+
completedAt: event.timestamp,
|
|
319
|
+
durationMs: event.durationMs,
|
|
320
|
+
error: event.error,
|
|
321
|
+
updatedAt: event.timestamp,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private cleanup(): void {
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
let changed = false;
|
|
328
|
+
|
|
329
|
+
for (const [traceId, trace] of this.traces) {
|
|
330
|
+
if (trace.completedAt && now - trace.completedAt > COMPLETED_TTL_MS) {
|
|
331
|
+
if (!changed) {
|
|
332
|
+
this.traces = new Map(this.traces);
|
|
333
|
+
changed = true;
|
|
334
|
+
}
|
|
335
|
+
this.traces.delete(traceId);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (changed) this.notify();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private notify(): void {
|
|
343
|
+
for (const listener of this.listeners) {
|
|
344
|
+
listener();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Singleton store instance */
|
|
350
|
+
let _store: TraceStore | null = null;
|
|
351
|
+
|
|
352
|
+
export function getTraceStore(): TraceStore {
|
|
353
|
+
if (!_store) {
|
|
354
|
+
_store = new TraceStore();
|
|
355
|
+
}
|
|
356
|
+
return _store;
|
|
357
|
+
}
|
package/src/LiveTrace.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { AnySpan } from "effect/Tracer";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LiveTrace — User-facing API for starting traced scopes.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* ```ts
|
|
8
|
+
* yield* pipe(
|
|
9
|
+
* myWorkflow,
|
|
10
|
+
* LiveTrace.withTrace({
|
|
11
|
+
* traceId: `doc:${documentId}`,
|
|
12
|
+
* label: "Processing report.pdf",
|
|
13
|
+
* scope: { type: "team", id: teamId },
|
|
14
|
+
* }),
|
|
15
|
+
* )
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Inside the scope, all `Effect.withSpan` and `Effect.log` calls
|
|
19
|
+
* are automatically captured and streamed to the frontend.
|
|
20
|
+
*/
|
|
21
|
+
import * as Effect from "effect/Effect";
|
|
22
|
+
import * as FiberRef from "effect/FiberRef";
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
LIVE_TRACE,
|
|
26
|
+
LIVE_TRACE_ID,
|
|
27
|
+
LIVE_TRACE_LABEL,
|
|
28
|
+
LIVE_TRACE_PROVIDER,
|
|
29
|
+
LIVE_TRACE_SCOPE_ID,
|
|
30
|
+
LIVE_TRACE_SCOPE_TYPE,
|
|
31
|
+
type TraceScope,
|
|
32
|
+
UI_STEP,
|
|
33
|
+
} from "./types.js";
|
|
34
|
+
|
|
35
|
+
export interface LiveTraceConfig {
|
|
36
|
+
/** Logical trace ID for stream routing (e.g., "doc:abc123") */
|
|
37
|
+
readonly traceId: string;
|
|
38
|
+
/** Human-readable label (e.g., filename) */
|
|
39
|
+
readonly label: string;
|
|
40
|
+
/** Stream routing scope */
|
|
41
|
+
readonly scope: TraceScope;
|
|
42
|
+
/** Optional provider key for source-page filtering (e.g. "notion", "google-drive") */
|
|
43
|
+
readonly provider?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* FiberRef that holds the current WrappedSpan (if inside a withTrace/step scope).
|
|
48
|
+
* Used by LiveTraceLogger to bridge Effect.log() → SpanEvent automatically.
|
|
49
|
+
* Inherited by child fibers so forked work stays attributed to the correct span.
|
|
50
|
+
*/
|
|
51
|
+
export const LiveSpanRef: FiberRef.FiberRef<AnySpan | null> = FiberRef.unsafeMake<AnySpan | null>(null);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* After Effect.withSpan creates the span, read it via Effect.currentSpan
|
|
55
|
+
* and store it in LiveSpanRef so the Logger can access it synchronously.
|
|
56
|
+
*/
|
|
57
|
+
const propagateSpan = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
58
|
+
Effect.gen(function* () {
|
|
59
|
+
const span = yield* Effect.currentSpan.pipe(Effect.orElseSucceed(() => null));
|
|
60
|
+
if (span) {
|
|
61
|
+
yield* FiberRef.set(LiveSpanRef, span);
|
|
62
|
+
}
|
|
63
|
+
return yield* effect;
|
|
64
|
+
}) as Effect.Effect<A, E, R>;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Wrap an effect in a live-traced scope.
|
|
68
|
+
*
|
|
69
|
+
* All `Effect.withSpan` and `Effect.log` calls within this scope
|
|
70
|
+
* are captured by the LiveTraceLayer and streamed to the sink.
|
|
71
|
+
*/
|
|
72
|
+
export const withTrace =
|
|
73
|
+
(config: LiveTraceConfig) =>
|
|
74
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
75
|
+
Effect.withSpan(propagateSpan(effect), config.label, {
|
|
76
|
+
attributes: {
|
|
77
|
+
[LIVE_TRACE]: true,
|
|
78
|
+
[LIVE_TRACE_ID]: config.traceId,
|
|
79
|
+
[LIVE_TRACE_LABEL]: config.label,
|
|
80
|
+
[LIVE_TRACE_SCOPE_TYPE]: config.scope.type,
|
|
81
|
+
[LIVE_TRACE_SCOPE_ID]: config.scope.id,
|
|
82
|
+
...(config.provider !== undefined ? { [LIVE_TRACE_PROVIDER]: config.provider } : {}),
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a traced step span. Shows as a top-level section in the UI.
|
|
88
|
+
*
|
|
89
|
+
* ```ts
|
|
90
|
+
* yield* LiveTrace.step("Parsing")(parseDocument(doc))
|
|
91
|
+
* yield* LiveTrace.step("Embedding")(embedChunks(chunks))
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export const step =
|
|
95
|
+
(name: string, attributes?: Record<string, unknown>) =>
|
|
96
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>
|
|
97
|
+
Effect.withSpan(propagateSpan(effect), name, {
|
|
98
|
+
attributes: { [UI_STEP]: true, ...attributes },
|
|
99
|
+
});
|
package/src/Logger.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiveTraceLogger — Bridges Effect.log() → SpanEvent automatically.
|
|
3
|
+
*
|
|
4
|
+
* When inside a `withTrace()` or `step()` scope, this Logger reads
|
|
5
|
+
* the current WrappedSpan from LiveSpanRef and calls span.event()
|
|
6
|
+
* so the log message appears in the trace card's step event list.
|
|
7
|
+
*
|
|
8
|
+
* Outside a traced scope, this Logger is a no-op (other loggers
|
|
9
|
+
* like Logger.pretty still handle the log normally).
|
|
10
|
+
*
|
|
11
|
+
* Wire via `Logger.add(liveTraceLogger)` in the services layer.
|
|
12
|
+
*/
|
|
13
|
+
import * as FiberRefs from "effect/FiberRefs";
|
|
14
|
+
import * as HashMap from "effect/HashMap";
|
|
15
|
+
import * as Logger from "effect/Logger";
|
|
16
|
+
|
|
17
|
+
import { LiveSpanRef } from "./LiveTrace.js";
|
|
18
|
+
import { isWrappedSpan } from "./WrappedSpan.js";
|
|
19
|
+
|
|
20
|
+
export const liveTraceLogger = Logger.make(({ message, logLevel, context: fiberRefs, annotations }) => {
|
|
21
|
+
const span = FiberRefs.getOrDefault(fiberRefs, LiveSpanRef);
|
|
22
|
+
if (!span || !isWrappedSpan(span)) return;
|
|
23
|
+
|
|
24
|
+
// Flatten message to string
|
|
25
|
+
const msg = Array.isArray(message) ? message.join(" ") : String(message);
|
|
26
|
+
|
|
27
|
+
const attrs: Record<string, unknown> = { "effect.logLevel": logLevel.label };
|
|
28
|
+
for (const [k, v] of HashMap.entries(annotations)) {
|
|
29
|
+
attrs[k] = v;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
span.event(msg, BigInt(Date.now()) * 1_000_000n, attrs);
|
|
33
|
+
});
|
package/src/Schema.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect Schema wrappers for TraceEvent types.
|
|
3
|
+
*
|
|
4
|
+
* Used for runtime NDJSON validation. Optional — consumers
|
|
5
|
+
* can use plain types from "./types" if they don't need validation.
|
|
6
|
+
*/
|
|
7
|
+
import * as Schema from "effect/Schema";
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Scope
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
export const TraceScopeSchema = Schema.Struct({
|
|
14
|
+
type: Schema.Literal("team", "org", "user"),
|
|
15
|
+
id: Schema.String,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Events
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
export const TraceStartSchema = Schema.Struct({
|
|
23
|
+
_tag: Schema.Literal("TraceStart"),
|
|
24
|
+
traceId: Schema.String,
|
|
25
|
+
label: Schema.String,
|
|
26
|
+
scope: TraceScopeSchema,
|
|
27
|
+
timestamp: Schema.Number,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const SpanStartSchema = Schema.Struct({
|
|
31
|
+
_tag: Schema.Literal("SpanStart"),
|
|
32
|
+
traceId: Schema.String,
|
|
33
|
+
spanId: Schema.String,
|
|
34
|
+
parentSpanId: Schema.optional(Schema.String),
|
|
35
|
+
name: Schema.String,
|
|
36
|
+
attributes: Schema.Record({ key: Schema.String, value: Schema.Unknown }),
|
|
37
|
+
timestamp: Schema.Number,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const SpanEndSchema = Schema.Struct({
|
|
41
|
+
_tag: Schema.Literal("SpanEnd"),
|
|
42
|
+
traceId: Schema.String,
|
|
43
|
+
spanId: Schema.String,
|
|
44
|
+
status: Schema.Literal("ok", "error"),
|
|
45
|
+
durationMs: Schema.Number,
|
|
46
|
+
timestamp: Schema.Number,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const SpanEventSchema = Schema.Struct({
|
|
50
|
+
_tag: Schema.Literal("SpanEvent"),
|
|
51
|
+
traceId: Schema.String,
|
|
52
|
+
spanId: Schema.String,
|
|
53
|
+
name: Schema.String,
|
|
54
|
+
level: Schema.optional(Schema.Literal("Debug", "Info", "Warning", "Error")),
|
|
55
|
+
attributes: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })),
|
|
56
|
+
timestamp: Schema.Number,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const TraceEndSchema = Schema.Struct({
|
|
60
|
+
_tag: Schema.Literal("TraceEnd"),
|
|
61
|
+
traceId: Schema.String,
|
|
62
|
+
status: Schema.Literal("completed", "failed"),
|
|
63
|
+
durationMs: Schema.Number,
|
|
64
|
+
error: Schema.optional(Schema.String),
|
|
65
|
+
timestamp: Schema.Number,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const TraceEventSchema = Schema.Union(TraceStartSchema, SpanStartSchema, SpanEndSchema, SpanEventSchema, TraceEndSchema);
|
|
69
|
+
|
|
70
|
+
export type TraceEventEncoded = Schema.Schema.Encoded<typeof TraceEventSchema>;
|
package/src/Sink.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TraceSink — Buffered event sink with pluggable transport.
|
|
3
|
+
*
|
|
4
|
+
* The sink has two parts:
|
|
5
|
+
* - TraceSinkHandle: synchronous emit() for use inside Tracer.span() (which is sync)
|
|
6
|
+
* - TraceSink: Effect service that manages the handle + flush daemon
|
|
7
|
+
*
|
|
8
|
+
* TraceTransport: pluggable backend (Durable Streams, SSE, WebSocket, console, etc.)
|
|
9
|
+
*/
|
|
10
|
+
import * as Context from "effect/Context";
|
|
11
|
+
import * as Effect from "effect/Effect";
|
|
12
|
+
import * as Layer from "effect/Layer";
|
|
13
|
+
import * as Schedule from "effect/Schedule";
|
|
14
|
+
|
|
15
|
+
import type { TraceEvent } from "./types.js";
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// TraceTransport — pluggable destination
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export interface TraceTransport {
|
|
22
|
+
/** Send a batch of events. Called periodically by the flush daemon. */
|
|
23
|
+
readonly send: (events: ReadonlyArray<TraceEvent>) => Effect.Effect<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class TraceTransportTag extends Context.Tag("@live-traces/TraceTransport")<TraceTransportTag, TraceTransport>() {}
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// TraceSinkHandle — the synchronous interface used by WrappedSpan
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
export interface TraceSinkHandle {
|
|
33
|
+
/** Synchronous, non-blocking. Buffers internally. */
|
|
34
|
+
readonly emit: (event: TraceEvent) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// TraceSink — Effect service managing buffer + flush lifecycle
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
export class TraceSink extends Context.Tag("@live-traces/TraceSink")<TraceSink, TraceSinkHandle>() {}
|
|
42
|
+
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Layer — creates a TraceSink backed by a TraceTransport
|
|
45
|
+
// ============================================================================
|
|
46
|
+
|
|
47
|
+
export interface TraceSinkConfig {
|
|
48
|
+
/** Flush interval in milliseconds. Default: 200 */
|
|
49
|
+
readonly flushIntervalMs?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const TraceSinkLive = (config?: TraceSinkConfig): Layer.Layer<TraceSink, never, TraceTransportTag> =>
|
|
53
|
+
Layer.scoped(
|
|
54
|
+
TraceSink,
|
|
55
|
+
Effect.gen(function* () {
|
|
56
|
+
const transport = yield* TraceTransportTag;
|
|
57
|
+
const intervalMs = config?.flushIntervalMs ?? 200;
|
|
58
|
+
|
|
59
|
+
let buffer: TraceEvent[] = [];
|
|
60
|
+
|
|
61
|
+
const flush = Effect.suspend(() => {
|
|
62
|
+
if (buffer.length === 0) return Effect.void;
|
|
63
|
+
const batch = buffer;
|
|
64
|
+
buffer = [];
|
|
65
|
+
return transport.send(batch);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Daemon fiber: flush every intervalMs
|
|
69
|
+
yield* flush.pipe(
|
|
70
|
+
Effect.schedule(Schedule.spaced(intervalMs)),
|
|
71
|
+
Effect.catchAllCause((cause) => Effect.logDebug("live-traces flush daemon error").pipe(Effect.annotateLogs("cause", String(cause)))),
|
|
72
|
+
Effect.forkScoped,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Final flush on scope close
|
|
76
|
+
yield* Effect.addFinalizer(() =>
|
|
77
|
+
flush.pipe(
|
|
78
|
+
Effect.catchAllCause((cause) =>
|
|
79
|
+
Effect.logDebug("live-traces finalizer flush error").pipe(Effect.annotateLogs("cause", String(cause))),
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const handle: TraceSinkHandle = {
|
|
85
|
+
emit: (event) => {
|
|
86
|
+
buffer.push(event);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return handle;
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// ============================================================================
|
|
95
|
+
// Console transport — for development/debugging
|
|
96
|
+
// ============================================================================
|
|
97
|
+
|
|
98
|
+
export const ConsoleTransport: TraceTransport = {
|
|
99
|
+
send: (events) =>
|
|
100
|
+
Effect.sync(() => {
|
|
101
|
+
for (const event of events) {
|
|
102
|
+
// eslint-disable-next-line no-console
|
|
103
|
+
console.log(`[live-trace] ${event._tag}`, JSON.stringify(event));
|
|
104
|
+
}
|
|
105
|
+
}),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const ConsoleTransportLayer: Layer.Layer<TraceTransportTag> = Layer.succeed(TraceTransportTag, ConsoleTransport);
|