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/src/Tracer.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { Span } from "effect/Tracer";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LiveTraceLayer — Effect Tracer decorator.
|
|
5
|
+
*
|
|
6
|
+
* Wraps whatever base tracer is in DefaultServices (native or OTel).
|
|
7
|
+
* Intercepts span creation within `LiveTrace.withTrace()` scopes
|
|
8
|
+
* and emits TraceEvents to a TraceSink.
|
|
9
|
+
*
|
|
10
|
+
* - Works standalone (no @effect/opentelemetry needed)
|
|
11
|
+
* - Works alongside OTel (wraps the OTel tracer, both systems run)
|
|
12
|
+
* - Zero FiberRef access in Tracer.span() — uses parent chain + attributes
|
|
13
|
+
*
|
|
14
|
+
* ## Layer composition: why ordering matters
|
|
15
|
+
*
|
|
16
|
+
* `Layer.setTracer` modifies the `currentServices` FiberRef — each call
|
|
17
|
+
* overwrites the previous tracer. When composing via `Layer.provideMerge`,
|
|
18
|
+
* the argument (`self`) is built FIRST. In a pipe chain:
|
|
19
|
+
*
|
|
20
|
+
* ```
|
|
21
|
+
* X.pipe(Layer.provideMerge(A), Layer.provideMerge(B))
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Build order: B (outermost self) → A (inner self) → X
|
|
25
|
+
*
|
|
26
|
+
* For LiveTraceLayer to wrap OTel's tracer, TelemetryLive must build
|
|
27
|
+
* BEFORE LiveTraceLayer so that `Effect.tracerWith` captures the OTel
|
|
28
|
+
* tracer. This means TelemetryLive must be OUTER (later in the pipe)
|
|
29
|
+
* and LiveTraceLayer must be INNER (earlier in the pipe):
|
|
30
|
+
*
|
|
31
|
+
* ```ts
|
|
32
|
+
* X.pipe(
|
|
33
|
+
* Layer.provideMerge(makeLiveTraceLayer()), // inner: builds 2nd, wraps OTel
|
|
34
|
+
* Layer.provideMerge(TelemetryLive), // outer: builds 1st, sets OTel
|
|
35
|
+
* )
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
import * as Effect from "effect/Effect";
|
|
39
|
+
import * as Layer from "effect/Layer";
|
|
40
|
+
import * as Option from "effect/Option";
|
|
41
|
+
import * as Tracer from "effect/Tracer";
|
|
42
|
+
|
|
43
|
+
import { TraceSink, type TraceSinkHandle } from "./Sink.js";
|
|
44
|
+
import { LIVE_TRACE, LIVE_TRACE_ID, LIVE_TRACE_SCOPE_ID, LIVE_TRACE_SCOPE_TYPE, type TraceScope } from "./types.js";
|
|
45
|
+
import { isWrappedSpan, shouldExclude, WrappedSpan } from "./WrappedSpan.js";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates a LiveTraceLayer that wraps the current tracer.
|
|
49
|
+
*
|
|
50
|
+
* **Important**: This layer reads the current tracer from the FiberRef
|
|
51
|
+
* at build time via `Effect.tracerWith`. For it to wrap the OTel tracer,
|
|
52
|
+
* TelemetryLive (or any layer that calls `Layer.setTracer`) must be
|
|
53
|
+
* built BEFORE this layer. In a `Layer.provideMerge` pipe chain, that
|
|
54
|
+
* means TelemetryLive should appear AFTER (outer) this layer:
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```ts
|
|
58
|
+
* const EnvLayer = ServerLive.pipe(
|
|
59
|
+
* Layer.provideMerge(ServicesLayer),
|
|
60
|
+
* // LiveTraceLayer BEFORE TelemetryLive in pipe = builds AFTER
|
|
61
|
+
* Layer.provideMerge(makeLiveTraceLayer()),
|
|
62
|
+
* // TelemetryLive AFTER in pipe = builds FIRST (sets OTel tracer)
|
|
63
|
+
* Layer.provideMerge(TelemetryLive),
|
|
64
|
+
* )
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export const LiveTraceLayer: Layer.Layer<never, never, TraceSink> = Layer.unwrapEffect(
|
|
68
|
+
Effect.gen(function* () {
|
|
69
|
+
// Capture the current tracer from DefaultServices FiberRef.
|
|
70
|
+
// When composed correctly (TelemetryLive outer, us inner),
|
|
71
|
+
// this captures the OTel tracer. When standalone, this
|
|
72
|
+
// captures the native tracer.
|
|
73
|
+
const baseTracer = yield* Effect.tracerWith(Effect.succeed);
|
|
74
|
+
const sink = yield* TraceSink;
|
|
75
|
+
|
|
76
|
+
const wrappedTracer = Tracer.make({
|
|
77
|
+
span(name, parent, context, links, startTime, kind, options) {
|
|
78
|
+
// Create the inner span via the base tracer (OTel or native)
|
|
79
|
+
const innerSpan = baseTracer.span(name, parent, context, links, startTime, kind, options);
|
|
80
|
+
|
|
81
|
+
// Should we exclude this span from live tracing?
|
|
82
|
+
if (shouldExclude(options?.attributes)) {
|
|
83
|
+
return innerSpan;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check: is this the root of a live-traced scope?
|
|
87
|
+
if (options?.attributes?.[LIVE_TRACE] === true) {
|
|
88
|
+
return createRootWrappedSpan(innerSpan, sink, options.attributes);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check: is the parent a WrappedSpan? (inside a traced scope)
|
|
92
|
+
if (Option.isSome(parent) && isWrappedSpan(parent.value)) {
|
|
93
|
+
const parentWrapped = parent.value;
|
|
94
|
+
const wrapped = new WrappedSpan(innerSpan, sink, parentWrapped.liveTraceId, parentWrapped.liveScope);
|
|
95
|
+
|
|
96
|
+
// IMPORTANT: options.attributes (e.g. "ui.step") are applied by Effect
|
|
97
|
+
// AFTER tracer.span() returns. innerSpan.attributes is empty at this point.
|
|
98
|
+
// We must include options.attributes directly.
|
|
99
|
+
sink.emit({
|
|
100
|
+
_tag: "SpanStart",
|
|
101
|
+
traceId: parentWrapped.liveTraceId,
|
|
102
|
+
spanId: innerSpan.spanId,
|
|
103
|
+
parentSpanId: parentWrapped.spanId,
|
|
104
|
+
name,
|
|
105
|
+
attributes: { ...Object.fromEntries(innerSpan.attributes), ...options?.attributes },
|
|
106
|
+
timestamp: Date.now(),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return wrapped;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Not in a traced scope — pass through unchanged
|
|
113
|
+
return innerSpan;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
context<X>(f: () => X, fiber: any): X {
|
|
117
|
+
// Delegate context propagation to the base tracer.
|
|
118
|
+
// This preserves OTel's OtelApi.context.with() behavior for
|
|
119
|
+
// W3C traceparent header propagation.
|
|
120
|
+
return baseTracer.context(f, fiber);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return Layer.setTracer(wrappedTracer);
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
function createRootWrappedSpan(innerSpan: Span, sink: TraceSinkHandle, attributes: Record<string, unknown>): WrappedSpan {
|
|
129
|
+
const traceId = attributes[LIVE_TRACE_ID] as string;
|
|
130
|
+
const scope: TraceScope = {
|
|
131
|
+
type: (attributes[LIVE_TRACE_SCOPE_TYPE] as TraceScope["type"]) ?? "user",
|
|
132
|
+
id: (attributes[LIVE_TRACE_SCOPE_ID] as string) ?? "unknown",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// Emit TraceStart
|
|
136
|
+
sink.emit({
|
|
137
|
+
_tag: "TraceStart",
|
|
138
|
+
traceId,
|
|
139
|
+
label: (attributes["live-trace.label"] as string) ?? innerSpan.name,
|
|
140
|
+
scope,
|
|
141
|
+
timestamp: Date.now(),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Emit SpanStart for the root span itself
|
|
145
|
+
// Include attributes from the options — Effect applies them AFTER tracer.span() returns
|
|
146
|
+
sink.emit({
|
|
147
|
+
_tag: "SpanStart",
|
|
148
|
+
traceId,
|
|
149
|
+
spanId: innerSpan.spanId,
|
|
150
|
+
name: innerSpan.name,
|
|
151
|
+
attributes: { ...Object.fromEntries(innerSpan.attributes), ...attributes },
|
|
152
|
+
timestamp: Date.now(),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const wrapped = new WrappedSpan(innerSpan, sink, traceId, scope);
|
|
156
|
+
|
|
157
|
+
// Override end to also emit TraceEnd after SpanEnd
|
|
158
|
+
const originalEnd = wrapped.end.bind(wrapped);
|
|
159
|
+
wrapped.end = (endTime: bigint, exit: any) => {
|
|
160
|
+
originalEnd(endTime, exit);
|
|
161
|
+
|
|
162
|
+
const startTime = innerSpan.status._tag === "Ended" ? innerSpan.status.startTime : BigInt(0);
|
|
163
|
+
const durationMs = Number(endTime - startTime) / 1_000_000;
|
|
164
|
+
|
|
165
|
+
sink.emit({
|
|
166
|
+
_tag: "TraceEnd",
|
|
167
|
+
traceId,
|
|
168
|
+
status: exit._tag === "Success" ? "completed" : "failed",
|
|
169
|
+
durationMs,
|
|
170
|
+
error: exit._tag === "Failure" ? String(exit.cause) : undefined,
|
|
171
|
+
timestamp: Date.now(),
|
|
172
|
+
});
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return wrapped;
|
|
176
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WrappedSpan — Span decorator that intercepts lifecycle events.
|
|
3
|
+
*
|
|
4
|
+
* Implements the full Tracer.Span interface by delegating to an inner span
|
|
5
|
+
* (OTel or native) while emitting TraceEvents to a TraceSink.
|
|
6
|
+
*
|
|
7
|
+
* Detection: Use `isWrappedSpan(span)` to check if a span is wrapped.
|
|
8
|
+
* The Symbol brand avoids instanceof checks across package boundaries.
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from "effect/Context";
|
|
11
|
+
import type { Exit } from "effect/Exit";
|
|
12
|
+
import type { Option } from "effect/Option";
|
|
13
|
+
import type { AnySpan, Span, SpanKind, SpanLink, SpanStatus } from "effect/Tracer";
|
|
14
|
+
|
|
15
|
+
import type { TraceSinkHandle } from "./Sink.js";
|
|
16
|
+
import type { TraceScope } from "./types.js";
|
|
17
|
+
|
|
18
|
+
import { TRACE_INTERNAL } from "./types.js";
|
|
19
|
+
|
|
20
|
+
export const LiveTraceSymbol: unique symbol = Symbol.for("@live-traces/WrappedSpan");
|
|
21
|
+
|
|
22
|
+
export class WrappedSpan implements Span {
|
|
23
|
+
readonly _tag = "Span" as const;
|
|
24
|
+
readonly [LiveTraceSymbol] = true;
|
|
25
|
+
|
|
26
|
+
/** The logical trace ID for routing (e.g., "doc:abc123") */
|
|
27
|
+
readonly liveTraceId: string;
|
|
28
|
+
|
|
29
|
+
/** Scope for stream routing */
|
|
30
|
+
readonly liveScope: TraceScope;
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
readonly inner: Span,
|
|
34
|
+
readonly sink: TraceSinkHandle,
|
|
35
|
+
liveTraceId: string,
|
|
36
|
+
liveScope: TraceScope,
|
|
37
|
+
) {
|
|
38
|
+
this.liveTraceId = liveTraceId;
|
|
39
|
+
this.liveScope = liveScope;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// -- Delegated reads --
|
|
43
|
+
|
|
44
|
+
get name(): string {
|
|
45
|
+
return this.inner.name;
|
|
46
|
+
}
|
|
47
|
+
get spanId(): string {
|
|
48
|
+
return this.inner.spanId;
|
|
49
|
+
}
|
|
50
|
+
get traceId(): string {
|
|
51
|
+
return this.inner.traceId;
|
|
52
|
+
}
|
|
53
|
+
get parent(): Option<AnySpan> {
|
|
54
|
+
return this.inner.parent;
|
|
55
|
+
}
|
|
56
|
+
get context(): Context<never> {
|
|
57
|
+
return this.inner.context;
|
|
58
|
+
}
|
|
59
|
+
get status(): SpanStatus {
|
|
60
|
+
return this.inner.status;
|
|
61
|
+
}
|
|
62
|
+
get attributes(): ReadonlyMap<string, unknown> {
|
|
63
|
+
return this.inner.attributes;
|
|
64
|
+
}
|
|
65
|
+
get links(): ReadonlyArray<SpanLink> {
|
|
66
|
+
return this.inner.links;
|
|
67
|
+
}
|
|
68
|
+
get sampled(): boolean {
|
|
69
|
+
return this.inner.sampled;
|
|
70
|
+
}
|
|
71
|
+
get kind(): SpanKind {
|
|
72
|
+
return this.inner.kind;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// -- Intercepted mutations --
|
|
76
|
+
|
|
77
|
+
attribute(key: string, value: unknown): void {
|
|
78
|
+
this.inner.attribute(key, value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
event(name: string, startTime: bigint, attributes?: Record<string, unknown>): void {
|
|
82
|
+
this.inner.event(name, startTime, attributes);
|
|
83
|
+
|
|
84
|
+
// Emit as SpanEvent to sink
|
|
85
|
+
const level = attributes?.["effect.logLevel"] as string | undefined;
|
|
86
|
+
this.sink.emit({
|
|
87
|
+
_tag: "SpanEvent",
|
|
88
|
+
traceId: this.liveTraceId,
|
|
89
|
+
spanId: this.spanId,
|
|
90
|
+
name,
|
|
91
|
+
level: normalizeLevel(level),
|
|
92
|
+
attributes,
|
|
93
|
+
timestamp: Date.now(),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
end(endTime: bigint, exit: Exit<unknown, unknown>): void {
|
|
98
|
+
this.inner.end(endTime, exit);
|
|
99
|
+
|
|
100
|
+
const startTime = this.inner.status._tag === "Ended" ? this.inner.status.startTime : BigInt(0);
|
|
101
|
+
const durationMs = Number(endTime - startTime) / 1_000_000;
|
|
102
|
+
const status = exit._tag === "Success" ? ("ok" as const) : ("error" as const);
|
|
103
|
+
|
|
104
|
+
this.sink.emit({
|
|
105
|
+
_tag: "SpanEnd",
|
|
106
|
+
traceId: this.liveTraceId,
|
|
107
|
+
spanId: this.spanId,
|
|
108
|
+
status,
|
|
109
|
+
durationMs,
|
|
110
|
+
timestamp: Date.now(),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
addLinks(links: ReadonlyArray<SpanLink>): void {
|
|
115
|
+
this.inner.addLinks(links);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const isWrappedSpan = (span: AnySpan): span is WrappedSpan => LiveTraceSymbol in span;
|
|
120
|
+
|
|
121
|
+
export const shouldExclude = (attributes?: Record<string, unknown>): boolean => attributes?.[TRACE_INTERNAL] === true;
|
|
122
|
+
|
|
123
|
+
function normalizeLevel(level: string | undefined): "Debug" | "Info" | "Warning" | "Error" | undefined {
|
|
124
|
+
if (!level) return undefined;
|
|
125
|
+
switch (level) {
|
|
126
|
+
case "DEBUG":
|
|
127
|
+
return "Debug";
|
|
128
|
+
case "INFO":
|
|
129
|
+
return "Info";
|
|
130
|
+
case "WARNING":
|
|
131
|
+
case "WARN":
|
|
132
|
+
return "Warning";
|
|
133
|
+
case "ERROR":
|
|
134
|
+
return "Error";
|
|
135
|
+
default:
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import * as Layer from "effect/Layer";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import type { TraceEvent } from "../types.js";
|
|
6
|
+
|
|
7
|
+
import { withTrace, step } from "../LiveTrace.js";
|
|
8
|
+
import { TraceSinkLive, TraceTransportTag } from "../Sink.js";
|
|
9
|
+
import { LiveTraceLayer } from "../Tracer.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates a test transport that collects events in an array.
|
|
13
|
+
* Returns the layer and a reference to the collected events.
|
|
14
|
+
*/
|
|
15
|
+
function makeTestTransport() {
|
|
16
|
+
const events: TraceEvent[] = [];
|
|
17
|
+
const transport = {
|
|
18
|
+
send: (batch: ReadonlyArray<TraceEvent>) =>
|
|
19
|
+
Effect.sync(() => {
|
|
20
|
+
events.push(...batch);
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
const layer = Layer.succeed(TraceTransportTag, transport);
|
|
24
|
+
return { events, layer };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Helper: build the full test layer (TraceSink + LiveTraceLayer)
|
|
29
|
+
* with a zero-interval flush for deterministic tests.
|
|
30
|
+
*/
|
|
31
|
+
function makeTestLayer(transportLayer: Layer.Layer<TraceTransportTag>) {
|
|
32
|
+
const sinkLayer = TraceSinkLive({ flushIntervalMs: 10 }).pipe(Layer.provide(transportLayer));
|
|
33
|
+
const traceLayer = LiveTraceLayer.pipe(Layer.provide(sinkLayer));
|
|
34
|
+
return Layer.merge(sinkLayer, traceLayer);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("LiveTraceLayer", () => {
|
|
38
|
+
it("captures TraceStart, SpanStart, SpanEnd, TraceEnd for a traced scope", async () => {
|
|
39
|
+
const { events, layer: transportLayer } = makeTestTransport();
|
|
40
|
+
const testLayer = makeTestLayer(transportLayer);
|
|
41
|
+
|
|
42
|
+
const program = withTrace({
|
|
43
|
+
traceId: "test:123",
|
|
44
|
+
label: "Test Workflow",
|
|
45
|
+
scope: { type: "team", id: "team-1" },
|
|
46
|
+
})(Effect.void);
|
|
47
|
+
|
|
48
|
+
await Effect.runPromise(
|
|
49
|
+
program.pipe(
|
|
50
|
+
Effect.provide(testLayer),
|
|
51
|
+
Effect.scoped,
|
|
52
|
+
// Give flush daemon time to fire
|
|
53
|
+
Effect.tap(() => Effect.sleep("50 millis")),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const tags = events.map((e) => e._tag);
|
|
58
|
+
expect(tags).toContain("TraceStart");
|
|
59
|
+
expect(tags).toContain("SpanStart");
|
|
60
|
+
expect(tags).toContain("SpanEnd");
|
|
61
|
+
expect(tags).toContain("TraceEnd");
|
|
62
|
+
|
|
63
|
+
// Verify TraceStart has correct metadata
|
|
64
|
+
const traceStart = events.find((e) => e._tag === "TraceStart");
|
|
65
|
+
expect(traceStart).toMatchObject({
|
|
66
|
+
_tag: "TraceStart",
|
|
67
|
+
traceId: "test:123",
|
|
68
|
+
label: "Test Workflow",
|
|
69
|
+
scope: { type: "team", id: "team-1" },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Verify TraceEnd
|
|
73
|
+
const traceEnd = events.find((e) => e._tag === "TraceEnd");
|
|
74
|
+
expect(traceEnd).toMatchObject({
|
|
75
|
+
_tag: "TraceEnd",
|
|
76
|
+
traceId: "test:123",
|
|
77
|
+
status: "completed",
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("captures nested child spans with parent-child relationships", async () => {
|
|
82
|
+
const { events, layer: transportLayer } = makeTestTransport();
|
|
83
|
+
const testLayer = makeTestLayer(transportLayer);
|
|
84
|
+
|
|
85
|
+
const program = withTrace({
|
|
86
|
+
traceId: "test:nested",
|
|
87
|
+
label: "Nested Test",
|
|
88
|
+
scope: { type: "team", id: "team-1" },
|
|
89
|
+
})(
|
|
90
|
+
Effect.gen(function* () {
|
|
91
|
+
yield* Effect.withSpan(Effect.void, "child-1");
|
|
92
|
+
yield* Effect.withSpan(Effect.void, "child-2");
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await Effect.runPromise(
|
|
97
|
+
program.pipe(
|
|
98
|
+
Effect.provide(testLayer),
|
|
99
|
+
Effect.scoped,
|
|
100
|
+
Effect.tap(() => Effect.sleep("50 millis")),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const spanStarts = events.filter((e) => e._tag === "SpanStart");
|
|
105
|
+
const spanEnds = events.filter((e) => e._tag === "SpanEnd");
|
|
106
|
+
|
|
107
|
+
// Root span + 2 children = 3 SpanStarts
|
|
108
|
+
expect(spanStarts.length).toBe(3);
|
|
109
|
+
expect(spanEnds.length).toBe(3);
|
|
110
|
+
|
|
111
|
+
// Children should reference root's spanId as parentSpanId
|
|
112
|
+
const rootSpan = spanStarts.find((e) => e._tag === "SpanStart" && e.name === "Nested Test");
|
|
113
|
+
const child1 = spanStarts.find((e) => e._tag === "SpanStart" && e.name === "child-1");
|
|
114
|
+
const child2 = spanStarts.find((e) => e._tag === "SpanStart" && e.name === "child-2");
|
|
115
|
+
|
|
116
|
+
expect(rootSpan).toBeDefined();
|
|
117
|
+
expect(child1).toBeDefined();
|
|
118
|
+
expect(child2).toBeDefined();
|
|
119
|
+
|
|
120
|
+
if (child1?._tag === "SpanStart" && rootSpan?._tag === "SpanStart") {
|
|
121
|
+
expect(child1.parentSpanId).toBe(rootSpan.spanId);
|
|
122
|
+
}
|
|
123
|
+
if (child2?._tag === "SpanStart" && rootSpan?._tag === "SpanStart") {
|
|
124
|
+
expect(child2.parentSpanId).toBe(rootSpan.spanId);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("captures Effect.log calls as SpanEvents via built-in tracerLogger", async () => {
|
|
129
|
+
const { events, layer: transportLayer } = makeTestTransport();
|
|
130
|
+
const testLayer = makeTestLayer(transportLayer);
|
|
131
|
+
|
|
132
|
+
const program = withTrace({
|
|
133
|
+
traceId: "test:logs",
|
|
134
|
+
label: "Log Test",
|
|
135
|
+
scope: { type: "team", id: "team-1" },
|
|
136
|
+
})(
|
|
137
|
+
Effect.gen(function* () {
|
|
138
|
+
yield* Effect.logInfo("Hello from traced scope");
|
|
139
|
+
yield* Effect.logWarning("A warning");
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
await Effect.runPromise(
|
|
144
|
+
program.pipe(
|
|
145
|
+
Effect.provide(testLayer),
|
|
146
|
+
Effect.scoped,
|
|
147
|
+
Effect.tap(() => Effect.sleep("50 millis")),
|
|
148
|
+
),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const spanEvents = events.filter((e) => e._tag === "SpanEvent");
|
|
152
|
+
expect(spanEvents.length).toBeGreaterThanOrEqual(2);
|
|
153
|
+
|
|
154
|
+
const infoEvent = spanEvents.find((e) => e._tag === "SpanEvent" && e.name.includes("Hello from traced scope"));
|
|
155
|
+
expect(infoEvent).toBeDefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("does not capture spans outside a traced scope", async () => {
|
|
159
|
+
const { events, layer: transportLayer } = makeTestTransport();
|
|
160
|
+
const testLayer = makeTestLayer(transportLayer);
|
|
161
|
+
|
|
162
|
+
const program = Effect.withSpan(Effect.void, "outside-span");
|
|
163
|
+
|
|
164
|
+
await Effect.runPromise(
|
|
165
|
+
program.pipe(
|
|
166
|
+
Effect.provide(testLayer),
|
|
167
|
+
Effect.scoped,
|
|
168
|
+
Effect.tap(() => Effect.sleep("50 millis")),
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// No trace events should be emitted
|
|
173
|
+
expect(events.length).toBe(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("LiveTrace.step creates ui.step attributed spans", async () => {
|
|
177
|
+
const { events, layer: transportLayer } = makeTestTransport();
|
|
178
|
+
const testLayer = makeTestLayer(transportLayer);
|
|
179
|
+
|
|
180
|
+
const program = withTrace({
|
|
181
|
+
traceId: "test:steps",
|
|
182
|
+
label: "Step Test",
|
|
183
|
+
scope: { type: "team", id: "team-1" },
|
|
184
|
+
})(
|
|
185
|
+
Effect.gen(function* () {
|
|
186
|
+
yield* step("Parsing")(Effect.void);
|
|
187
|
+
yield* step("Embedding")(Effect.void);
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
await Effect.runPromise(
|
|
192
|
+
program.pipe(
|
|
193
|
+
Effect.provide(testLayer),
|
|
194
|
+
Effect.scoped,
|
|
195
|
+
Effect.tap(() => Effect.sleep("50 millis")),
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const spanStarts = events.filter((e) => e._tag === "SpanStart");
|
|
200
|
+
const parsingSpan = spanStarts.find((e) => e._tag === "SpanStart" && e.name === "Parsing");
|
|
201
|
+
const embeddingSpan = spanStarts.find((e) => e._tag === "SpanStart" && e.name === "Embedding");
|
|
202
|
+
|
|
203
|
+
expect(parsingSpan).toBeDefined();
|
|
204
|
+
expect(embeddingSpan).toBeDefined();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("handles failed traces correctly", async () => {
|
|
208
|
+
const { events, layer: transportLayer } = makeTestTransport();
|
|
209
|
+
const testLayer = makeTestLayer(transportLayer);
|
|
210
|
+
|
|
211
|
+
const program = withTrace({
|
|
212
|
+
traceId: "test:fail",
|
|
213
|
+
label: "Fail Test",
|
|
214
|
+
scope: { type: "team", id: "team-1" },
|
|
215
|
+
})(Effect.fail("boom"));
|
|
216
|
+
|
|
217
|
+
await Effect.runPromise(
|
|
218
|
+
program.pipe(
|
|
219
|
+
Effect.provide(testLayer),
|
|
220
|
+
Effect.scoped,
|
|
221
|
+
Effect.tap(() => Effect.sleep("50 millis")),
|
|
222
|
+
Effect.catchAll(() => Effect.void),
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const traceEnd = events.find((e) => e._tag === "TraceEnd");
|
|
227
|
+
expect(traceEnd).toMatchObject({
|
|
228
|
+
_tag: "TraceEnd",
|
|
229
|
+
traceId: "test:fail",
|
|
230
|
+
status: "failed",
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const spanEnd = events.find((e) => e._tag === "SpanEnd");
|
|
234
|
+
expect(spanEnd).toMatchObject({
|
|
235
|
+
status: "error",
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE TraceTransport - Server-Sent Events transport.
|
|
3
|
+
*
|
|
4
|
+
* Pushes batched trace events into an in-process broker. The broker fans
|
|
5
|
+
* batches out to any number of subscribers (HTTP connections holding an
|
|
6
|
+
* `EventSource`). Stream routing is per-`TraceScope` - a subscriber gets
|
|
7
|
+
* only events for the scope it subscribed to.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { Layer } from "effect";
|
|
11
|
+
* import {
|
|
12
|
+
* LiveTraceLayer, TraceSinkLive,
|
|
13
|
+
* SSETransportLayer, getSseBroker,
|
|
14
|
+
* } from "live-traces";
|
|
15
|
+
*
|
|
16
|
+
* // Server layer
|
|
17
|
+
* const TraceLive = LiveTraceLayer.pipe(
|
|
18
|
+
* Layer.provide(TraceSinkLive({ flushIntervalMs: 100 })),
|
|
19
|
+
* Layer.provide(SSETransportLayer),
|
|
20
|
+
* );
|
|
21
|
+
*
|
|
22
|
+
* // HTTP handler
|
|
23
|
+
* app.get("/traces/:scopeType/:scopeId", (req, res) => {
|
|
24
|
+
* res.setHeader("Content-Type", "text/event-stream");
|
|
25
|
+
* const unsubscribe = getSseBroker().subscribe(
|
|
26
|
+
* { type: req.params.scopeType, id: req.params.scopeId },
|
|
27
|
+
* (events) => res.write(`data: ${JSON.stringify(events)}\n\n`),
|
|
28
|
+
* );
|
|
29
|
+
* req.on("close", unsubscribe);
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
import * as Effect from "effect/Effect";
|
|
34
|
+
import * as Layer from "effect/Layer";
|
|
35
|
+
|
|
36
|
+
import type { TraceEvent, TraceScope } from "../types.js";
|
|
37
|
+
|
|
38
|
+
import { TraceTransportTag, type TraceTransport } from "../Sink.js";
|
|
39
|
+
|
|
40
|
+
type Subscriber = (events: ReadonlyArray<TraceEvent>) => void;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* In-process pub/sub broker. Routes trace events to subscribers keyed by
|
|
44
|
+
* `scope.type/scope.id`. A subscriber for `team/abc` receives only events
|
|
45
|
+
* for that team's traces.
|
|
46
|
+
*/
|
|
47
|
+
export class SseBroker {
|
|
48
|
+
private readonly subscribers = new Map<string, Set<Subscriber>>();
|
|
49
|
+
private readonly scopeByTraceId = new Map<string, TraceScope>();
|
|
50
|
+
|
|
51
|
+
private key(scope: TraceScope): string {
|
|
52
|
+
return `${scope.type}/${scope.id}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Subscribe to events for a specific scope. Returns an unsubscribe fn. */
|
|
56
|
+
subscribe(scope: TraceScope, listener: Subscriber): () => void {
|
|
57
|
+
const k = this.key(scope);
|
|
58
|
+
let set = this.subscribers.get(k);
|
|
59
|
+
if (!set) {
|
|
60
|
+
set = new Set();
|
|
61
|
+
this.subscribers.set(k, set);
|
|
62
|
+
}
|
|
63
|
+
set.add(listener);
|
|
64
|
+
return () => {
|
|
65
|
+
const s = this.subscribers.get(k);
|
|
66
|
+
if (!s) return;
|
|
67
|
+
s.delete(listener);
|
|
68
|
+
if (s.size === 0) this.subscribers.delete(k);
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Publish a batch - fan out per scope. Called by the transport. */
|
|
73
|
+
publish(events: ReadonlyArray<TraceEvent>): void {
|
|
74
|
+
for (const e of events) {
|
|
75
|
+
if (e._tag === "TraceStart") this.scopeByTraceId.set(e.traceId, e.scope);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const grouped = new Map<string, TraceEvent[]>();
|
|
79
|
+
for (const e of events) {
|
|
80
|
+
const scope = this.scopeByTraceId.get(e.traceId);
|
|
81
|
+
if (!scope) continue;
|
|
82
|
+
const k = this.key(scope);
|
|
83
|
+
const arr = grouped.get(k) ?? [];
|
|
84
|
+
arr.push(e);
|
|
85
|
+
grouped.set(k, arr);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const [k, batch] of grouped) {
|
|
89
|
+
const set = this.subscribers.get(k);
|
|
90
|
+
if (!set) continue;
|
|
91
|
+
for (const listener of set) {
|
|
92
|
+
try {
|
|
93
|
+
listener(batch);
|
|
94
|
+
} catch {
|
|
95
|
+
// Subscriber threw - keep the broker healthy.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const e of events) {
|
|
101
|
+
if (e._tag === "TraceEnd") this.scopeByTraceId.delete(e.traceId);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Active subscriber count for a scope (useful for tests / metrics). */
|
|
106
|
+
subscriberCount(scope: TraceScope): number {
|
|
107
|
+
return this.subscribers.get(this.key(scope))?.size ?? 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let _broker: SseBroker | null = null;
|
|
112
|
+
|
|
113
|
+
/** Singleton broker. Same instance is used by transport + HTTP handlers. */
|
|
114
|
+
export function getSseBroker(): SseBroker {
|
|
115
|
+
if (!_broker) _broker = new SseBroker();
|
|
116
|
+
return _broker;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const sseTransport: TraceTransport = {
|
|
120
|
+
send: (events) =>
|
|
121
|
+
Effect.sync(() => {
|
|
122
|
+
getSseBroker().publish(events);
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Effect Layer wiring the SSE transport into the trace sink. */
|
|
127
|
+
export const SSETransportLayer: Layer.Layer<TraceTransportTag> = Layer.succeed(TraceTransportTag, sseTransport);
|