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.
Effect.log / logDebug / logInfo / logWarning / logError / logFatalLogger.withMinimumLogLevelEffect.annotateLogsEffect.withLogSpan('label')Logger.replace / Logger.none// 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...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)
) 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 everywhereAdd 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
})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")
)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 = ...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)
)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.
Effect.withSpan('name')Effect.annotateCurrentSpanMetric.counter('name')Metric.histogram('name', { boundaries })Metric.gauge('name')Supervisor.track// 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" })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)
) 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) ──────┐ │ │
│ │ └───────────────────────────┘ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘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 }
})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 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
})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
})