10·03Observability

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.

58 - Observability: Spans, Annotations, and the OTel Layer

Previous · Next

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 automatically

Each 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 constructor Effect.fn ties spans to function names.
  • Reference, repos/effect/packages/opentelemetry/examples/ for runnable exporter setups, and repos/effect/packages/opentelemetry/src/NodeSdk.ts for the full Configuration shape.

Run the tests: pnpm test 58-observability


The full test suite

The index.test.ts for this lesson, verbatim from steps/58-observability/.

index.test.ts
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)
})
})
← PreviousHTTP Client PatternsNext →CLI Patterns