58 - Observability: Spans, Annotations, and the OTel Layer
Observability in Effect is part of the runtime, not a separate library to thread through function signatures. The fiber carries a tracer, a log handler, and an annotation context as ambient state.
Effect.log* emits structured records; Effect.withSpan opens a span; Effect.annotateLogs and Effect.annotateCurrentSpan attach context. In development the default Logger writes to the console; in production a single NodeSdk.layer from @effect/opentelemetry ships everything to OpenTelemetry.
The program code does not change.
What you’ll work through
index.test.ts contains four beats: attaching structured context with Effect.annotateLogs, opening a named span with Effect.withSpan, combining Effect.fn with spans for automatic per-call labelling, and swapping in the OTel layer so the same program ships to a real observability backend.
By the end your business code should be agnostic to the observability backend — logs, spans, and annotations flow through the runtime, not through function signatures.
What the Runtime Tracks
Effect runtime (per fiber) ───────────────────────── Logger ◄── Effect.log, Effect.logInfo, … Annotations ◄── Effect.annotateLogs (logs) ◄── Effect.annotateCurrentSpan (spans) Tracer ◄── Effect.withSpan (open) / span.end (close) ◄── child spans nest automaticallyEach is configurable via a Layer. Replace the default Logger; provide a tracer; the rest of the program is unchanged.
Structured Logging
Creates structured log records on the current fiber. Levels mirror the standard set, logDebug, logInfo, logWarning, logError, logFatal.
import { Effect } from "effect"
// ┌─── Effect<void, never, never>// ▼const program = Effect.gen(function* () { yield* Effect.logInfo("checkout started") yield* Effect.logWarning("retrying upstream", { attempt: 2 })})Details
Variadic arguments after the message are passed through as a structured record. They are not template-string-interpolated, the runtime keeps message and context separate so a downstream log shipper can index them.
Effect.annotateLogs
Adds structured key/value pairs to every log emitted by the wrapped effect.
import { Effect } from "effect"
const handle = (requestId: string) => Effect.gen(function* () { yield* Effect.logInfo("received") yield* Effect.logInfo("done") }).pipe(Effect.annotateLogs({ requestId }))When to Use
For per-request context, request id, user id, trace id, that should appear on every log line within the scope. Prefer it over string interpolation; downstream log queries become indexable.
Effect.withSpan
Opens a span around an effect. Child spans nest automatically when one withSpan-wrapped effect runs inside another.
import { Effect } from "effect"
const fetchUser = (id: string) => Effect.gen(function* () { yield* Effect.annotateCurrentSpan("user.id", id) return { id, name: "Ada" } }).pipe(Effect.withSpan("Users.fetch", { attributes: { component: "users" } }))
const checkout = (id: string) => Effect.gen(function* () { const user = yield* fetchUser(id) return user }).pipe(Effect.withSpan("checkout"))
// Trace tree when run:// checkout// └── Users.fetch (user.id="user-1")Details
A failure inside the span is recorded on it before the span closes. Attributes set with Effect.annotateCurrentSpan after the span opens are visible in the exporter.
Effect.fn: Spans by Function Name
Effect.fn("name") defines a generator-bodied function and wraps each call in a span named "name". Idiomatic for service methods.
import { Effect } from "effect"
const fetchUser = Effect.fn("Users.fetch")(function* (id: string) { yield* Effect.annotateCurrentSpan("user.id", id) return { id, name: "Ada" }})When to Use
Any service method or workflow step that you would otherwise pipe through Effect.withSpan. The function name and the span name stay in sync automatically.
Instrument a workflow from scratch
Effect.withSpan opens a named trace span around a workflow; nested calls produce a parent/child trace tree. Effect.annotateLogs attaches structured key/value pairs to every log record inside the scope.
Both are additive — the business logic is unchanged — and both compose with the service patterns from step 23.
import { Effect } from "effect"
const checkout = (orderId: string) => Effect.gen(function* () { yield* Effect.log("checkout.start") yield* validateOrder(orderId) yield* chargeCard(orderId) yield* shipOrder(orderId) }).pipe( Effect.withSpan("Checkout.run"), // one span around the whole flow Effect.annotateLogs({ orderId, component: "checkout" }), // every log inside carries these )Run the program through a Logger.pretty layer and the annotations show up next to every log line; run it through the OTel layer below and the spans flow to your collector. Same code, more signals.
Your turn wrap a small workflow in `Effect.withSpan` and `Effect.annotateLogs`, then capture the emitted logs with a test logger and assert both the span name and the annotated keys appear.
Wiring OpenTelemetry
Configures the tracer, metric reader, and log processor with one Layer. Mirrors the example in repos/effect/packages/opentelemetry/examples/otlp-exporter.ts.
import { NodeSdk } from "@effect/opentelemetry"import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base"import { Effect } from "effect"
const TracingLive = NodeSdk.layer(() => ({ resource: { serviceName: "checkout" }, spanProcessor: new BatchSpanProcessor( new OTLPTraceExporter({ url: "http://localhost:4318/v1/traces" }), ),}))
Effect.runFork(program.pipe(Effect.provide(TracingLive)))Details
NodeSdk.layer accepts a thunk so the SDK is constructed lazily, the layer is the only place that imports @opentelemetry/*. For local development swap OTLPTraceExporter for ConsoleSpanExporter and BatchSpanProcessor for SimpleSpanProcessor; everything else stays the same.
When to Use
Any service that ships to production. The same layer also wires metric readers (metricReader) and structured log exporters (logRecordProcessor) when those fields are populated, one composition root for all three signals.
A Composite Example
Annotates a request scope, opens nested spans, and threads the trace context through a service-driven workflow.
import { Effect } from "effect"
const fetchUser = Effect.fn("Users.fetch")(function* (id: string) { yield* Effect.annotateCurrentSpan("user.id", id) return { id, name: "Ada" }})
const sendReceipt = Effect.fn("Receipts.send")(function* (userId: string) { yield* Effect.logInfo("queued receipt") return userId})
// ┌─── Effect<void, never, never>// ▼const handleCheckout = (requestId: string, userId: string) => Effect.gen(function* () { const user = yield* fetchUser(userId) yield* sendReceipt(user.id) }).pipe( Effect.annotateLogs({ requestId }), Effect.withSpan("checkout", { attributes: { "request.id": requestId } }), )The trace tree looks like checkout → Users.fetch → Receipts.send. Every log inside the scope carries requestId.
None of this requires passing a logger or tracer through the function arguments.
Common Pitfalls
1, console.log in Effect code. It bypasses the runtime’s logger entirely, levels, annotations, and span correlation are lost. Use Effect.log*.
2, Interpolating context into messages. logInfo(\user ${id} logged in`)is unindexable. Pass{ userId }as a structured argument or useannotateLogs`.
3, Anonymous workflows in traces. A span named "<anonymous>" is useless. Use Effect.fn("Component.action") or Effect.withSpan with an explicit name.
4, Forgetting the production layer. Without Effect.provide(TracingLive) the SDK is never initialised; spans are created but never exported. The layer is the only production-vs-development knob.
flows into every nested log. - [ ] Open a named span with `Effect.withSpan` and inspect the captured attributes. - [ ] Combine `Effect.fn` with spans — every call gets a named span for free. - [ ] Swap the OTel Layer in production; the program code does not change.
See Also
- Previous,
57-http-client-patterns: where spans first earn their keep. - Related,
27-effect-fn: the constructorEffect.fnties spans to function names. - Reference,
repos/effect/packages/opentelemetry/examples/for runnable exporter setups, andrepos/effect/packages/opentelemetry/src/NodeSdk.tsfor the fullConfigurationshape.
Run the tests: pnpm test 58-observability
The full test suite
The index.test.ts for this lesson, verbatim from steps/58-observability/.
import { describe, expect, it } from "vite-plus/test"import { Effect, Metric } from "effect"
describe("observability runtime services", () => { it("adds structured log annotations and a tracing span", async () => { const program = Effect.log("checkout complete").pipe( Effect.annotateLogs({ orderId: "o-1" }), Effect.withSpan("Checkout.complete", { attributes: { orderId: "o-1" } }), ) await Effect.runPromise(program) })
it("records metrics through the runtime", async () => { const counter = Metric.counter("tutorial.requests").pipe(Metric.withConstantInput(1)) const value = await Effect.runPromise(Effect.track(Effect.void, counter).pipe(Effect.andThen(Metric.value(counter)))) expect(value.count).toBeGreaterThanOrEqual(1) })})