Effect TS

A course for TypeScript developers

Observability

2 steps
17
Structured Logging
From console.log to composable logs
40 min

In TypeScript you scatter console.log calls everywhere, lose context in production, and bolt on Winston/Pino as an afterthought. Effect treats logging as a first-class Effect — composable, structured, annotated, and swappable without changing application code.

Key InsightEffect.log is an Effect, not a side effect. It composes in pipes and generators, carries structured data, and the logger implementation is a service you can swap via Layers — no code changes needed to go from dev console to production JSON to OpenTelemetry.
What to learn
Effect.log / logDebug / logInfo / logWarning / logError / logFatal
Log at different levels. Each returns an Effect — you must yield* or pipe it. Default level is INFO (logDebug is hidden unless you change the minimum).
Logger.withMinimumLogLevel
Controls which log levels are visible. Wrap an effect to filter noise: pipe(myEffect, Logger.withMinimumLogLevel(LogLevel.Debug)).
Effect.annotateLogs
Attaches key-value pairs to every log within a scope. Think of it like adding context (userId, requestId) that flows through all nested logs automatically.
Effect.withLogSpan('label')
Wraps a section of code in a named timing span. Logs inside show elapsed time — useful for performance debugging without manual Date.now() bookkeeping.
Logger.replace / Logger.none
Swap the logger implementation via Layers. Logger.none silences all logs (great for tests). You can provide a custom JSON logger for production.
In TypeScript
// TypeScript: scattered console.log, no structure
function processOrder(order: Order) {
  console.log("Processing order", order.id)
  try {
    const result = chargeCard(order)
    console.log("Order processed", order.id, result)
    return result
  } catch (err) {
    console.error("Order failed", order.id, err)
    throw err
  }
}

// Want structured JSON logs? Install winston/pino,
// create a logger instance, pass it everywhere,
// update every call site...
With Effect
import { Effect, Logger, LogLevel } from "effect"

// ── Logging is an Effect — it composes naturally ──
const processOrder = (order: Order) =>
  Effect.gen(function* () {
    yield* Effect.log("Processing order")
    const result = yield* chargeCard(order)
    yield* Effect.log("Order processed")
    return result
  }).pipe(
    // annotations flow to ALL logs inside this scope
    Effect.annotateLogs("orderId", order.id),
    Effect.annotateLogs("customer", order.customerId),
    // timing span — logs show elapsed time
    Effect.withLogSpan("processOrder")
  )

// ── Swap the logger without touching application code ──
// Dev: default pretty logger
// Test: silence everything
const testProgram = processOrder(order).pipe(
  Effect.provide(Logger.none)
)
// Prod: set minimum level
const prodProgram = processOrder(order).pipe(
  Logger.withMinimumLogLevel(LogLevel.Warning)
)
How it works
  TypeScript                          Effect
  ─────────────────────────          ─────────────────────────────
  console.log("msg")                 yield* Effect.log("msg")
  │                                  │
  ├─ fire-and-forget                 ├─ returns Effect (composable)
  ├─ no levels                       ├─ logDebug/Info/Warning/Error
  ├─ no structure                    ├─ annotateLogs({ key: val })
  ├─ hardcoded destination           ├─ withLogSpan("timing")
  └─ refactor to change              └─ swap via Layer (0 code changes)

  winston.createLogger({...})        Logger layer
  ├─ import logger everywhere        ├─ provided once at the edge
  ├─ pass instance around            ├─ flows through all Effects
  └─ different API per library       └─ same Effect.log everywhere
Practice
Add structured logging to a pipeline

Add logging to this order processing pipeline. Use appropriate log levels, annotate with the orderId, and wrap in a log span.

import { Effect } from "effect"

interface Order { id: string; amount: number }

const validateOrder = (order: Order) =>
  order.amount > 0
    ? Effect.succeed(order)
    : Effect.fail("Invalid amount")

const chargeCard = (order: Order) =>
  Effect.succeed({ transactionId: "tx_123" })

// TODO: Add logging, annotations, and a log span
const processOrder = (order: Order) =>
  Effect.gen(function* () {
    const validated = yield* validateOrder(order)
    const result = yield* chargeCard(validated)
    return result
  })
Reveal solution
import { Effect } from "effect"

interface Order { id: string; amount: number }

const validateOrder = (order: Order) =>
  order.amount > 0
    ? Effect.succeed(order)
    : Effect.fail("Invalid amount")

const chargeCard = (order: Order) =>
  Effect.succeed({ transactionId: "tx_123" })

const processOrder = (order: Order) =>
  Effect.gen(function* () {
    yield* Effect.log("Validating order")
    const validated = yield* validateOrder(order)
    yield* Effect.log("Charging card")
    const result = yield* chargeCard(validated)
    yield* Effect.log(`Charged successfully: ${result.transactionId}`)
    return result
  }).pipe(
    Effect.annotateLogs("orderId", order.id),
    Effect.withLogSpan("processOrder")
  )
Silence logs in tests

The program below logs heavily. Run it with all logs silenced (for tests), then run it showing only Warning and above (for prod).

import { Effect, Logger, LogLevel } from "effect"

const noisyProgram = Effect.gen(function* () {
  yield* Effect.logDebug("debug details")
  yield* Effect.log("processing...")
  yield* Effect.logWarning("disk almost full")
  return 42
})

// TODO: Run with no logs (test mode)
// const testResult = ...

// TODO: Run showing only Warning+ (prod mode)
// const prodResult = ...
Reveal solution
import { Effect, Logger, LogLevel } from "effect"

const noisyProgram = Effect.gen(function* () {
  yield* Effect.logDebug("debug details")
  yield* Effect.log("processing...")
  yield* Effect.logWarning("disk almost full")
  return 42
})

// Test mode: silence all logs
const testResult = noisyProgram.pipe(
  Effect.provide(Logger.none)
)

// Prod mode: Warning and above only
const prodResult = noisyProgram.pipe(
  Logger.withMinimumLogLevel(LogLevel.Warning)
)
Common TrapEffect.log returns an Effect — you must yield* it or include it in a pipe. console.log works inside Effect.gen but loses all structured logging benefits (no levels, no annotations, no spans, can't be swapped).
Read docs →
18
Tracing, Metrics & Supervision
Spans, counters, and fiber monitoring
45 min

Distributed tracing and metrics usually require heavy instrumentation libraries and manual plumbing. Effect bakes them in: withSpan creates trace spans, Metric gives you type-safe counters/histograms/gauges, and Supervisor lets you monitor fiber lifecycles — all composable and exportable to OpenTelemetry.

Key InsightEffect.withSpan wraps any Effect in a tracing span with automatic parent-child nesting, error capture, and timing. Metrics are declared once and used like Effects. Both integrate with OpenTelemetry for zero-config export to Grafana, Jaeger, Datadog, etc.
What to learn
Effect.withSpan('name')
Wraps an Effect in a tracing span. Captures timing, errors, and automatically nests child spans. The most common tracing API you'll use.
Effect.annotateCurrentSpan
Adds key-value attributes to the current span. Like log annotations but for traces — helps filter/search in your tracing backend.
Metric.counter('name')
A monotonically increasing counter (e.g., requests served, errors occurred). Increment with Metric.increment or pipe through your Effect.
Metric.histogram('name', { boundaries })
Tracks value distributions (e.g., response times, payload sizes). Records observations automatically when piped through Effects.
Metric.gauge('name')
A point-in-time value (e.g., active connections, queue depth). Can go up or down unlike counters.
Supervisor.track
Monitors fiber lifecycle (creation and termination). Use with Effect.supervised to observe concurrent work — how many fibers are active, when they finish.
In TypeScript
// TypeScript: manual OpenTelemetry instrumentation
import { trace } from "@opentelemetry/api"

const tracer = trace.getTracer("my-service")

async function processOrder(order: Order) {
  // manual span creation — verbose and error-prone
  const span = tracer.startSpan("processOrder")
  span.setAttribute("orderId", order.id)
  try {
    const result = await chargeCard(order)
    span.setStatus({ code: SpanStatusCode.OK })
    return result
  } catch (err) {
    span.setStatus({ code: SpanStatusCode.ERROR })
    span.recordException(err)
    throw err
  } finally {
    span.end() // easy to forget!
  }
}

// Metrics: yet another library, separate API
import { metrics } from "@opentelemetry/api"
const counter = metrics.getMeter("my-service")
  .createCounter("orders_processed")
counter.add(1, { status: "success" })
With Effect
import { Effect, Metric } from "effect"

// ── Tracing: just wrap with withSpan ──
// Attributes live OUTSIDE the business logic via withSpan options
const processOrder = (order: Order) =>
  Effect.gen(function* () {
    const result = yield* chargeCard(order)
    return result
  }).pipe(
    Effect.withSpan("processOrder", {
      attributes: { orderId: order.id, amount: order.amount }
    })
  )

// Use annotateCurrentSpan only when the value is computed INSIDE the generator
const processOrderDynamic = Effect.gen(function* () {
  const result = yield* chargeCard(order)
  // value only known after chargeCard — must annotate here
  yield* Effect.annotateCurrentSpan("transactionId", result.txId)
  return result
}).pipe(Effect.withSpan("processOrder"))

// Nested spans create parent-child traces automatically
const handleRequest = (req: Request) =>
  Effect.gen(function* () {
    const order = yield* parseOrder(req)  // no span
    const result = yield* processOrder(order) // child span
    yield* sendConfirmation(order)        // no span
    return result
  }).pipe(Effect.withSpan("handleRequest")) // parent span

// ── Metrics: declare once, use in pipelines ──
const orderCounter = Metric.counter("orders_processed")
const latencyHistogram = Metric.histogram("order_latency_ms", {
  boundaries: [10, 50, 100, 500, 1000]
})

// Increment counter on each order
const countedOrder = processOrder(order).pipe(
  Metric.increment(orderCounter)
)

// Track timing with histogram
const timedOrder = processOrder(order).pipe(
  Metric.trackDuration(latencyHistogram)
)
How it works
  TypeScript + OpenTelemetry           Effect
  ────────────────────────────        ─────────────────────────
  const span = tracer.startSpan()     Effect.withSpan("name")
  try { ... }                         │ automatic timing
  catch { span.recordException() }    │ automatic error capture
  finally { span.end() }              │ automatic nesting
  ── 8 lines per span ──              └─ 1 line per span

  Metrics (separate library):          Metric module:
  meter.createCounter("x")            Metric.counter("x")
  counter.add(1)                      Metric.increment(counter)
  ── imperative, manual ──            └─ composable in pipes

  Span hierarchy (same in both):
  ┌─ handleRequest (400ms) ─────────────────────┐
  │  ┌─ processOrder (350ms) ──────────────┐    │
  │  │  ┌─ chargeCard (200ms) ──────┐      │    │
  │  │  └───────────────────────────┘      │    │
  │  └─────────────────────────────────────┘    │
  └─────────────────────────────────────────────┘
Practice
Add tracing spans to a pipeline

Add tracing spans to this multi-step pipeline. The outer function should have a parent span, and each database call should have its own child span. Pass userId as a span attribute (outside the business logic).

import { Effect } from "effect"

const fetchUser = (id: string) =>
  Effect.succeed({ id, name: "Alice" })

const fetchOrders = (userId: string) =>
  Effect.succeed([{ id: "order_1", amount: 42 }])

// TODO: Add spans — parent "getUser WithOrders",
// children "fetchUser" and "fetchOrders"
// Annotate parent span with userId
const getUserWithOrders = (userId: string) =>
  Effect.gen(function* () {
    const user = yield* fetchUser(userId)
    const orders = yield* fetchOrders(userId)
    return { user, orders }
  })
Reveal solution
import { Effect } from "effect"

const fetchUser = (id: string) =>
  Effect.succeed({ id, name: "Alice" }).pipe(
    Effect.withSpan("fetchUser")
  )

const fetchOrders = (userId: string) =>
  Effect.succeed([{ id: "order_1", amount: 42 }]).pipe(
    Effect.withSpan("fetchOrders")
  )

const getUserWithOrders = (userId: string) =>
  Effect.gen(function* () {
    const user = yield* fetchUser(userId)
    const orders = yield* fetchOrders(userId)
    return { user, orders }
  }).pipe(
    // attributes outside the business logic
    Effect.withSpan("getUserWithOrders", {
      attributes: { userId }
    })
  )
Create and use a metric

Create a counter metric that tracks how many users are fetched. Wire it so the counter increments every time fetchUser succeeds.

import { Effect, Metric } from "effect"

const fetchUser = (id: string) =>
  Effect.succeed({ id, name: "Alice" })

// TODO: Create a counter metric "users_fetched"
// TODO: Wire it to fetchUser so it increments on success

const program = Effect.gen(function* () {
  yield* fetchUser("user_1")
  yield* fetchUser("user_2")
  yield* fetchUser("user_3")
  // counter should be 3 after this
})
Reveal solution
import { Effect, Metric } from "effect"

const usersFetched = Metric.counter("users_fetched")

const fetchUser = (id: string) =>
  Effect.succeed({ id, name: "Alice" }).pipe(
    Metric.increment(usersFetched)
  )

const program = Effect.gen(function* () {
  yield* fetchUser("user_1")
  yield* fetchUser("user_2")
  yield* fetchUser("user_3")
  // counter is now 3
})
Common TrapEffect.withSpan adds overhead — don't wrap every single function. Use it at meaningful operation boundaries (HTTP handlers, DB queries, external calls). Over-spanning creates noise in your tracing backend.
Read docs →
Schema & DataConcurrency