1/**
2 * Low-level devtools client and tracer wiring over the current `Socket`.
3 *
4 * `DevToolsClient` speaks the devtools NDJSON protocol used by the unstable
5 * devtools integration. It sends span starts, span events, span completions,
6 * ping messages, and metric snapshots through a socket, then exposes tracer
7 * layers that forward telemetry while preserving the current tracer's behavior.
8 *
9 * @stability unstable
10 * @since 4.0.0
11 */
12import * as Cause from "../Cause.ts"
13import * as Context from "../Context.ts"
14import * as Deferred from "../Deferred.ts"
15import * as Effect from "../Effect.ts"
16import * as Ndjson from "../encoding/Ndjson.ts"
17import * as Fiber from "../Fiber.ts"
18import * as Layer from "../Layer.ts"
19import * as Metric from "../Metric.ts"
20import * as Queue from "../Queue.ts"
21import * as Schema from "../Schema.ts"
22import type * as Scope from "../Scope.ts"
23import * as Socket from "../socket/Socket.ts"
24import * as Stream from "../Stream.ts"
25import * as Tracer from "../Tracer.ts"
26import * as DevToolsSchema from "./DevToolsSchema.ts"
27
28const RequestSchema = Schema.toCodecJson(DevToolsSchema.Request)
29const ResponseSchema = Schema.toCodecJson(DevToolsSchema.Response)
30
31/**
32 * Service for sending span and span-event telemetry to the Effect devtools
33 * connection.
34 *
35 * @stability unstable
36 * @category services
37 * @since 4.0.0
38 */
39export class DevToolsClient extends Context.Service<
40 DevToolsClient,
41 {
42 readonly sendUnsafe: (
43 _: DevToolsSchema.Span | DevToolsSchema.SpanEvent
44 ) => void
45 }
46>()("effect/devtools/DevToolsClient") {}
47
48const makeEffect = Effect.gen(function*() {
49 const socket = yield* Socket.Socket
50 const services = yield* Effect.context<never>()
51 const requests = yield* Queue.unbounded<DevToolsSchema.Request>()
52 const connected = yield* Deferred.make<void>()
53
54 const offerMetricsSnapshot = Effect.sync(() => {
55 Queue.offerUnsafe(requests, toMetricsSnapshot(services))
56 })
57
58 const handleResponse = (
59 response: DevToolsSchema.Response
60 ): Effect.Effect<void> => {
61 switch (response._tag) {
62 case "MetricsRequest": {
63 return offerMetricsSnapshot
64 }
65 case "Pong": {
66 return Effect.void
67 }
68 }
69 }
70
71 const fiber = yield* Stream.fromQueue(requests).pipe(
72 Stream.pipeThroughChannel(
73 Ndjson.duplexSchemaString(Socket.toChannelString(socket), {
74 inputSchema: RequestSchema,
75 outputSchema: ResponseSchema
76 })
77 ),
78 Stream.onFirst(() => Deferred.completeWith(connected, Effect.void)),
79 Stream.runForEach(handleResponse),
80 Effect.forkDetach
81 )
82
83 yield* Effect.addFinalizer(() =>
84 offerMetricsSnapshot.pipe(
85 Effect.andThen(
86 Effect.flatMap(Effect.fiberId, (id) => Queue.failCause(requests, Cause.interrupt(id)))
87 ),
88 Effect.andThen(Fiber.await(fiber))
89 )
90 )
91
92 yield* Effect.suspend(() => Queue.offer(requests, { _tag: "Ping" })).pipe(
93 Effect.delay("3 seconds"),
94 Effect.forever,
95 Effect.forkScoped
96 )
97
98 yield* Deferred.await(connected).pipe(
99 Effect.timeoutOption("1 second"),
100 Effect.asVoid
101 )
102
103 return DevToolsClient.of({
104 sendUnsafe(request: DevToolsSchema.Span | DevToolsSchema.SpanEvent) {
105 Queue.offerUnsafe(requests, request)
106 }
107 })
108})
109
110const toMetricsSnapshot = (
111 context: Context.Context<never>
112): DevToolsSchema.MetricsSnapshot => ({
113 _tag: "MetricsSnapshot",
114 metrics: Metric.snapshotUnsafe(context)
115})
116
117/**
118 * Creates a devtools client over the current `Socket`, speaking the devtools
119 * NDJSON protocol, sending periodic pings, and responding to metrics snapshot
120 * requests.
121 *
122 * **When to use**
123 *
124 * Use when you already have a `Socket` and need the low-level `DevToolsClient`
125 * service to exchange devtools telemetry directly.
126 *
127 * **Details**
128 *
129 * The effect requires `Scope` because it starts background fibers for the socket
130 * stream and heartbeat.
131 *
132 * **Gotchas**
133 *
134 * `make` creates only the client service; tracing is installed separately.
135 *
136 * @see {@link layer} for providing the client as a layer
137 * @see {@link makeTracer} for creating a tracer after a `DevToolsClient` is available
138 * @see {@link layerTracer} for creating the client from the current `Socket` and installing the tracer as a layer
139 *
140 * @stability unstable
141 * @category constructors
142 * @since 4.0.0
143 */
144export const make: Effect.Effect<
145 DevToolsClient["Service"],
146 never,
147 Scope.Scope | Socket.Socket
148> = makeEffect.pipe(
149 Effect.annotateLogs({
150 module: "DevTools",
151 service: "Client"
152 })
153)
154
155/**
156 * Layer that provides `DevToolsClient` using the current `Socket`.
157 *
158 * **When to use**
159 *
160 * Use to provide the low-level `DevToolsClient` service over an existing
161 * `Socket` for custom devtools integrations that send telemetry through the
162 * client directly.
163 *
164 * **Details**
165 *
166 * It delegates to `make`, so it speaks the devtools NDJSON protocol over the
167 * provided `Socket`, sends periodic pings, responds to metrics snapshot
168 * requests, and finalizes its background fibers when the layer scope closes.
169 *
170 * **Gotchas**
171 *
172 * This layer only provides the client. It does not install the devtools tracer
173 * by itself.
174 *
175 * @see {@link make} for constructing the client as a scoped effect instead of a layer
176 * @see {@link layerTracer} for a higher-level layer that creates the client and installs the devtools tracer
177 *
178 * @stability unstable
179 * @category layers
180 * @since 4.0.0
181 */
182export const layer: Layer.Layer<DevToolsClient, never, Socket.Socket> = Layer.effect(DevToolsClient, make)
183
184const spanSnapshot = (span: Tracer.Span): DevToolsSchema.Span => ({
185 _tag: "Span",
186 spanId: span.spanId,
187 traceId: span.traceId,
188 name: span.name,
189 sampled: span.sampled,
190 attributes: new Map(span.attributes),
191 status: span.status,
192 parent: span.parent
193})
194
195const makeTracerEffect = Effect.gen(function*() {
196 const client = yield* DevToolsClient
197 const currentTracer = yield* Effect.tracer
198
199 return Tracer.make({
200 span(options) {
201 const span = currentTracer.span(options)
202 client.sendUnsafe(spanSnapshot(span))
203 const oldEvent = span.event
204 span.event = function(this: Tracer.Span, name, startTime, attributes) {
205 client.sendUnsafe({
206 _tag: "SpanEvent",
207 traceId: span.traceId,
208 spanId: span.spanId,
209 name,
210 startTime,
211 attributes
212 })
213 return oldEvent.call(this, name, startTime, attributes)
214 }
215
216 const oldEnd = span.end
217 span.end = function(this: Tracer.Span, endTime, exit) {
218 oldEnd.call(this, endTime, exit)
219 client.sendUnsafe(spanSnapshot(span))
220 }
221
222 return span
223 },
224 context: currentTracer.context
225 })
226})
227
228/**
229 * Creates a tracer that delegates to the current tracer while sending span
230 * starts, span events, and span ends to `DevToolsClient`.
231 *
232 * @stability unstable
233 * @category constructors
234 * @since 4.0.0
235 */
236export const makeTracer: Effect.Effect<Tracer.Tracer, never, DevToolsClient> = makeTracerEffect.pipe(
237 Effect.annotateLogs({
238 module: "DevTools",
239 service: "Tracer"
240 })
241)
242
243/**
244 * Layer that creates a `DevToolsClient` from the current `Socket` and installs
245 * the devtools tracer.
246 *
247 * @stability unstable
248 * @category layers
249 * @since 4.0.0
250 */
251export const layerTracer: Layer.Layer<never, never, Socket.Socket> = Layer.effect(Tracer.Tracer, makeTracer).pipe(
252 Layer.provide(layer)
253)