Skip to content

Tracing

A trace records the path a request takes through your system as a tree of spans, each measuring one unit of work. Effect builds this tree automatically: when you wrap an effect with Effect.withSpan, any span created inside that effect becomes a child, because parent/child relationships follow the fiber’s context. You annotate spans with attributes, and at the edge of the app you provide an exporter layer to ship the whole tree to a tracing backend.

import { Context, Effect, Layer } from "effect"
export class Checkout extends Context.Service<Checkout, {
processCheckout(orderId: string): Effect.Effect<void>
}>()("acme/Checkout") {
static readonly layer = Layer.effect(
Checkout,
Effect.gen(function*() {
return Checkout.of({
// Effect.fn names the function and opens a span around each call.
processCheckout: Effect.fn("Checkout.processCheckout")(function*(orderId) {
yield* Effect.logInfo("starting checkout", { orderId })
// A child span for the card charge, with attributes describing it.
yield* Effect.sleep("50 millis").pipe(
Effect.withSpan("checkout.charge-card"),
Effect.annotateSpans({
"checkout.order_id": orderId,
"checkout.provider": "acme-pay"
})
)
// A sibling child span for persistence.
yield* Effect.sleep("20 millis").pipe(
Effect.withSpan("checkout.persist-order")
)
yield* Effect.logInfo("checkout completed", { orderId })
})
})
})
)
}

Calling processCheckout produces a Checkout.processCheckout span with two children, checkout.charge-card and checkout.persist-order. Each carries its own duration, and the attributes you attach show up on the corresponding span in your backend.

Effect.withSpan(name, options) wraps an effect in a span that starts when the effect begins and ends when it completes (including on failure or interruption). The options mirror OpenTelemetry: attributes, kind ("server" | "client" | "internal" | "producer" | "consumer"), links, root, and more.

import { Effect } from "effect"
const handler = Effect.gen(function*() {
yield* Effect.sleep("10 millis")
}).pipe(
Effect.withSpan("handle-request", {
kind: "server",
attributes: { "http.method": "POST", "http.route": "/checkout" }
})
)

Effect.fn("name")(function*…) is the idiomatic way to define an effect-returning function: it both names the function for stack traces and opens a span named after it for every call — so most of your spans come for free just from writing functions this way.

Use Effect.annotateSpans to attach attributes to every span created within an effect, or Effect.annotateCurrentSpan to add an attribute to the innermost span only. You can also reach the active span directly with Effect.currentSpan.

import { Effect } from "effect"
const work = Effect.gen(function*() {
// Add an attribute to the currently active span.
yield* Effect.annotateCurrentSpan("cache.hit", false)
// Access the span object itself when you need its trace/span ids.
const span = yield* Effect.currentSpan
yield* Effect.logInfo("inside span", { spanId: span.spanId, traceId: span.traceId })
}).pipe(Effect.withSpan("work"))

Effect.withSpanScoped ties a span’s lifetime to a Scope instead of a single effect — handy when a span should stay open across several steps of a resource’s lifecycle. Layer.withSpan wraps the construction of a layer in a span, which is useful for tracing application startup.

import { Effect, Layer } from "effect"
// Trace the work done while a layer is being built.
const Setup = Layer.effectDiscard(
Effect.gen(function*() {
yield* Effect.logInfo("running migrations")
yield* Effect.sleep("30 millis")
}).pipe(Effect.withSpan("startup.migrate"))
).pipe(Layer.withSpan("startup"))

By itself, Effect builds the span tree but does not send it anywhere. The OtlpTracer module from effect/unstable/observability posts spans to any OpenTelemetry-compatible collector. It needs two supporting layers: an OTLP serializer (OtlpSerialization.layerJson) and an HttpClient to do the POSTing (FetchHttpClient.layer).

import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpLogger, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"
// Export spans to a local OTLP collector.
const OtlpTracing = OtlpTracer.layer({
url: "http://localhost:4318/v1/traces",
resource: {
serviceName: "checkout-api",
serviceVersion: "1.0.0",
attributes: { "deployment.environment": "staging" }
}
})
// Optionally export log records the same way, so logs and traces correlate.
const OtlpLogging = OtlpLogger.layer({
url: "http://localhost:4318/v1/logs",
resource: { serviceName: "checkout-api", serviceVersion: "1.0.0" }
})
// A single reusable observability layer for the whole app.
export const ObservabilityLayer = Layer.merge(OtlpTracing, OtlpLogging).pipe(
// OtlpTracer/OtlpLogger require a serializer and an HttpClient.
Layer.provide(OtlpSerialization.layerJson),
Layer.provide(FetchHttpClient.layer)
)

The resource describes the service emitting the telemetry: serviceName and serviceVersion populate the OTLP resource, and attributes adds arbitrary resource-level tags. You can also tune exportInterval, maxBatchSize, and pass headers (for example an auth token to a hosted collector).

Provide the observability layer last, after your application layers, so that every span the app creates is captured before being exported.

import { NodeRuntime } from "@effect/platform-node"
import { Effect, Layer } from "effect"
const Run = Layer.effectDiscard(
Effect.gen(function*() {
const checkout = yield* Checkout
yield* checkout.processCheckout("ord_123")
}).pipe(Effect.withSpan("checkout-test-run"))
).pipe(
Layer.provide(Checkout.layer),
// Provide observability at the very end so all spans are exported.
Layer.provide(ObservabilityLayer)
)
Layer.launch(Run).pipe(NodeRuntime.runMain)