Skip to content

DevTools

DevTools installs a tracer that mirrors the current tracer and streams its telemetry to an external devtools process: span starts, span events, span completions, and on-demand metric snapshots. The transport is a Socket — typically a WebSocket — defaulting to ws://localhost:34437.

The mental model is important: DevTools only installs the client side. The devtools server (the UI / collector you connect to) must already be running and reachable at the configured URL. The client connects, sends periodic Ping heartbeats, forwards every span as it is created/updated/ended, and replies with a MetricsSnapshot whenever the server sends a MetricsRequest.

Because it wraps the current tracer rather than replacing it, DevTools composes with your normal tracing setup — see Tracing for spans and Metrics for the metric types that are snapshotted.

Import DevTools from effect/unstable/devtools and provide DevTools.layer() to your runtime. Every span you create with Effect.withSpan (and every span its children create) is forwarded to the devtools process.

import { Effect } from "effect"
import { DevTools } from "effect/unstable/devtools"
const program = Effect.gen(function* () {
yield* Effect.log("doing work")
yield* Effect.sleep("100 millis")
}).pipe(Effect.withSpan("work"))
// `DevTools.layer()` uses the global WebSocket and connects to
// ws://localhost:34437 by default. The span tree shows up in the
// connected devtools UI.
program.pipe(
Effect.provide(DevTools.layer()),
Effect.runFork
)

DevTools.layer() uses globalThis.WebSocket, so it requires nothing else and returns a Layer<never>. Point it at a different endpoint by passing a URL:

import { DevTools } from "effect/unstable/devtools"
const DevToolsLive = DevTools.layer("ws://localhost:9000")

DevTools ships three layers that differ only in how the underlying Socket is obtained.

import { Layer } from "effect"
import { DevTools } from "effect/unstable/devtools"
import { Socket } from "effect/unstable/socket"
// 1. Global WebSocket — no requirements (most apps).
const a: Layer.Layer<never> = DevTools.layer()
// 2. WebSocket, but you provide the WebSocketConstructor
// (e.g. a Node `ws` polyfill in a non-browser runtime).
const b: Layer.Layer<never, never, Socket.WebSocketConstructor> =
DevTools.layerWebSocket("ws://localhost:34437")
// 3. Install the tracer over a Socket you already have.
const c: Layer.Layer<never, never, Socket.Socket> = DevTools.layerSocket

When the runtime has no global WebSocket, use layerWebSocket and supply a constructor (Effect ships Socket.layerWebSocketConstructorGlobal, or you can provide your own):

import { Layer } from "effect"
import { DevTools } from "effect/unstable/devtools"
import { Socket } from "effect/unstable/socket"
const DevToolsLive = DevTools.layerWebSocket("ws://localhost:34437").pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal)
)

The application-facing entry point. Import the DevTools namespace from effect/unstable/devtools.

layer(url = "ws://localhost:34437"): Layer<never> — the common entry point. Installs the devtools tracer over a WebSocket using the global WebSocket constructor, so the resulting layer has no remaining requirements.

import { DevTools } from "effect/unstable/devtools"
DevTools.layer() // => Layer<never> (connects to ws://localhost:34437)
DevTools.layer("ws://localhost:9000") // => Layer<never>

layerWebSocket(url = "ws://localhost:34437"): Layer<never, never, Socket.WebSocketConstructor> — like layer, but leaves WebSocketConstructor as an unfilled requirement so you decide which WebSocket implementation to use.

import { Layer } from "effect"
import { DevTools } from "effect/unstable/devtools"
import { Socket } from "effect/unstable/socket"
DevTools.layerWebSocket("ws://localhost:34437").pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal)
) // => Layer<never>

layerSocket: Layer<never, never, Socket.Socket> — installs the devtools tracer over an existing Socket transport (it is an alias for DevToolsClient.layerTracer). Use this when an integration already produced a Socket.

import { Layer } from "effect"
import { DevTools } from "effect/unstable/devtools"
// Provide your own Socket layer (e.g. a TCP socket) and the tracer
// streams telemetry over it.
declare const SocketLive: Layer.Layer<
import("effect/unstable/socket").Socket.Socket
>
const DevToolsLive = DevTools.layerSocket.pipe(Layer.provide(SocketLive))

Low-level client and tracer wiring over a Socket. Import the DevToolsClient namespace from effect/unstable/devtools. Most readers only need the DevTools layers above; reach for this module to build custom transports or to send telemetry by hand.

A Context.Service tagged "effect/devtools/DevToolsClient" whose only member is sendUnsafe(span | spanEvent) — it enqueues a Span or SpanEvent onto the socket stream with no back pressure. It is meant for the tracer hot path.

import { Effect } from "effect"
import { DevToolsClient } from "effect/unstable/devtools"
const sendEvent = Effect.gen(function* () {
const client = yield* DevToolsClient
client.sendUnsafe({
_tag: "SpanEvent",
traceId: "trace-1",
spanId: "span-1",
name: "checkpoint",
startTime: 0n,
attributes: { stage: "init" }
})
// => void (enqueued, fire-and-forget)
})

make: Effect<DevToolsClient, never, Scope | Socket.Socket> — builds the client service over the current Socket. It is scoped because it forks background fibers for the socket stream and the 3-second ping heartbeat, and registers a finalizer that flushes a final metrics snapshot on shutdown.

import { Effect } from "effect"
import { DevToolsClient } from "effect/unstable/devtools"
const program = Effect.gen(function* () {
const client = yield* DevToolsClient.make // requires Scope + Socket
// ...use client.sendUnsafe(...)
})

layer: Layer<DevToolsClient, never, Socket.Socket> — provides the DevToolsClient service from the current Socket via make. It only provides the client; it does not install the devtools tracer.

import { DevToolsClient } from "effect/unstable/devtools"
DevToolsClient.layer // => Layer<DevToolsClient, never, Socket.Socket>

makeTracer: Effect<Tracer.Tracer, never, DevToolsClient> — creates a Tracer that delegates to the current tracer while sending each span’s start, events, and end to the DevToolsClient. This is the wrapping tracer that powers the higher layers.

import { Effect, Layer, Tracer } from "effect"
import { DevToolsClient } from "effect/unstable/devtools"
// Build the forwarding tracer and install it as the runtime tracer.
const TracerLive = Layer.effect(Tracer.Tracer, DevToolsClient.makeTracer)
// => Layer<never, never, DevToolsClient>

layerTracer: Layer<never, never, Socket.Socket> — combines layer and makeTracer: it creates the client from the current Socket and installs the forwarding tracer. DevTools.layerSocket is exactly this layer.

import { DevToolsClient } from "effect/unstable/devtools"
DevToolsClient.layerTracer // => Layer<never, never, Socket.Socket>

DevToolsServer reference (building a server)

Section titled “DevToolsServer reference (building a server)”

Runs the server side of the protocol — useful if you are building the collector that devtools clients connect to. Import from effect/unstable/devtools. It decodes NDJSON messages per connection, answers Ping with Pong internally, and hands every other request to your handler. It does not interpret spans or metrics itself.

The per-connection handle passed to your handler:

import type { Queue, Effect } from "effect"
import type { DevToolsSchema } from "effect/unstable/devtools"
interface Client {
// Non-ping requests received from this socket: Span | SpanEvent | MetricsSnapshot
readonly queue: Queue.Dequeue<DevToolsSchema.Request.WithoutPing>
// Send a non-pong response back to the client: MetricsRequest
readonly send: (_: DevToolsSchema.Response.WithoutPong) => Effect.Effect<void>
}

The queue is scoped to the socket-processing fiber and is shut down when that fiber terminates — treat it as per-connection state, not a process-wide bus.

run(handle): Effect<never, SocketServerError, R | SocketServer> — runs the socket server, requiring a SocketServer. Each accepted socket becomes one Client; Ping is answered with Pong automatically, and all other requests are offered to client.queue.

import { Effect, Queue } from "effect"
import { DevToolsServer } from "effect/unstable/devtools"
// A minimal server: drain incoming telemetry and periodically request metrics.
const server = DevToolsServer.run((client) =>
Effect.gen(function* () {
// ask the client for a metrics snapshot
yield* client.send({ _tag: "MetricsRequest" })
// process everything the client sends (spans, span events, snapshots)
yield* Queue.take(client.queue).pipe(
Effect.flatMap((request) => Effect.log("devtools request", request._tag)),
Effect.forever
)
})
)
// server: Effect<never, SocketServerError, SocketServer>
// provide a SocketServer layer (e.g. a WebSocket server) to run it.

The DevToolsSchema namespace (from effect/unstable/devtools) defines the serialized message shapes exchanged over the socket. These describe transport payloads, not the in-memory tracer/metric structures: timestamps are bigints, ended-span exits drop their success value via Exit.asVoid, and attributes are intentionally open-ended. You only touch these schemas when implementing a custom transport; the table below is an index — read the source for the full field definitions.

SchemaDirectionDescription
Spanclient → serverSpan telemetry: id, trace id, name, sampled flag, attributes, status, optional parent.
ExternalSpanembeddedParent context for a span created outside the current span tree (spanId, traceId, sampled).
ParentSpanembeddedA span’s parent — union of Span and ExternalSpan.
SpanEventclient → serverA named event on a span (traceId, spanId, name, startTime, optional attributes).
SpanStatusStartedembeddedSpan status with startTime only (not yet ended).
SpanStatusEndedembeddedSpan status with startTime, endTime, and encoded exit.
SpanStatusembeddedUnion of SpanStatusStarted and SpanStatusEnded.
Pingclient → serverHeartbeat sent by the client every 3 seconds.
Pongserver → clientHeartbeat reply (sent automatically by DevToolsServer).
MetricsRequestserver → clientAsks the client to send its current metrics.
MetricsSnapshotclient → serverCarries an array of Metric snapshots.
MetricLabelembeddedA metric label { key, value } pair.
MetricembeddedUnion of Counter, Frequency, Gauge, Histogram, Summary.
Counterembedded{ count, incremental }.
Frequencyembedded{ occurrences: ReadonlyMap<string, number> }.
Gaugeembedded{ value }.
Histogramembedded{ buckets, count, min, max, sum }.
Summaryembedded{ quantiles, count, min, max, sum }.
RequestunionMessages from client → server: Ping | Span | SpanEvent | MetricsSnapshot.
ResponseunionMessages from server → client: Pong | MetricsRequest.
Request.WithoutPingtypeRequest excluding Ping — what DevToolsServer handlers see.
Response.WithoutPongtypeResponse excluding Pong — what handlers may send.
import { Schema } from "effect"
import { DevToolsSchema } from "effect/unstable/devtools"
// Decode an incoming wire message in a custom transport.
const decodeRequest = Schema.decodeUnknownSync(DevToolsSchema.Request)
decodeRequest({ _tag: "Ping" }) // => { _tag: "Ping" }