Skip to content

Metrics

Where logs and traces describe individual events, metrics describe aggregate behaviour over time: how many requests were served, how much memory is in use, what the latency distribution looks like. Effect keeps a process-wide metric registry, so a Metric you define is just a handle — defining it twice with the same name and type refers to the same underlying state. You declare the metric once, update it as your code runs, and export the registry to Prometheus or OTLP at the edge.

import { Effect, Metric } from "effect"
// Declare metrics once, as module-level constants.
const requestsTotal = Metric.counter("http_requests_total", {
description: "Total HTTP requests handled"
})
const inFlight = Metric.gauge("http_requests_in_flight", {
description: "Requests currently being processed"
})
const latency = Metric.timer("http_request_duration", {
description: "Request handling latency"
})
const handleRequest = Effect.fn("handleRequest")(function*(route: string) {
yield* Metric.update(requestsTotal, 1) // increment the counter
yield* Metric.modify(inFlight, 1) // one more request in flight
// Effect.timed returns [duration, result]; feed the duration to the timer.
const [duration] = yield* Effect.sleep("25 millis").pipe(Effect.timed)
yield* Metric.update(latency, duration)
yield* Metric.modify(inFlight, -1) // request finished
})

Each metric type answers a different question, and Effect provides five: counter, gauge, histogram, summary, and frequency.

A counter tracks a cumulative value that normally only goes up — request totals, errors, bytes processed. Metric.update adds to it.

import { Effect, Metric } from "effect"
const errors = Metric.counter("errors_total", {
description: "Total errors encountered"
})
const program = Effect.gen(function*() {
yield* Metric.update(errors, 1)
yield* Metric.update(errors, 1)
// Read the current state for assertions or debugging.
const state = yield* Metric.value(errors)
yield* Effect.logInfo("error count", { count: state.count })
})

Pass bigint: true for counters that exceed the safe integer range, or incremental: true to reject decreasing updates. To count occurrences of an effect regardless of a numeric input, Metric.withConstantInput fixes the update value so you can pipe the metric straight onto an effect.

A gauge holds a single value that moves up and down — memory usage, queue depth, active connections. Metric.update sets it; Metric.modify adds a delta.

import { Effect, Metric } from "effect"
const queueDepth = Metric.gauge("queue_depth", {
description: "Items waiting in the queue"
})
const program = Effect.gen(function*() {
yield* Metric.update(queueDepth, 10) // set absolute value
yield* Metric.modify(queueDepth, 3) // now 13
yield* Metric.modify(queueDepth, -5) // now 8
const state = yield* Metric.value(queueDepth)
yield* Effect.logInfo("queue depth", { value: state.value })
})

A histogram sorts observations into buckets so you can see a distribution — typically request latencies or payload sizes. You supply the bucket boundaries; Metric.linearBoundaries and Metric.exponentialBoundaries generate common shapes.

import { Effect, Metric } from "effect"
const responseSize = Metric.histogram("response_size_kb", {
description: "Distribution of response sizes in KB",
// Exponential buckets with boundaries 1, 2, 4, 8, 16, 32, 64 KB, plus an
// overflow bucket above 64 KB.
boundaries: Metric.exponentialBoundaries({ start: 1, factor: 2, count: 8 })
})
const program = Effect.gen(function*() {
yield* Metric.update(responseSize, 3.2) // lands in the 2–4 KB bucket
yield* Metric.update(responseSize, 40) // lands in the 32–64 KB bucket
const state = yield* Metric.value(responseSize)
yield* Effect.logInfo("response sizes", {
count: state.count,
min: state.min,
max: state.max,
sum: state.sum
})
})

For latency specifically, Metric.timer is a histogram that accepts a Duration and records milliseconds, with sensible default boundaries. Pair it with Effect.timed (as in the opening example) to capture how long an effect took and feed the Duration straight into the timer.

A summary, like a histogram, describes a distribution — but it computes quantiles on the fly instead of bucketing, and it ages out old observations. Use it when you care about percentiles (p50, p95, p99) over a rolling window.

import { Duration, Effect, Metric } from "effect"
const apiLatency = Metric.summary("api_latency_ms", {
description: "API latency quantiles over a 5-minute window",
maxAge: Duration.minutes(5), // drop observations older than 5 minutes
maxSize: 1000, // keep at most 1000 samples in memory
quantiles: [0.5, 0.9, 0.95, 0.99] // percentiles to compute
})
const program = Effect.gen(function*() {
yield* Metric.update(apiLatency, 120)
yield* Metric.update(apiLatency, 240)
})

A frequency counts how often each distinct string value appears — HTTP status codes, feature flags, error tags. It maintains an occurrence count per unique value with no need to know the values ahead of time.

import { Effect, Metric } from "effect"
const statusCodes = Metric.frequency("http_status_codes", {
description: "Count of responses by status code"
})
const program = Effect.gen(function*() {
yield* Metric.update(statusCodes, "200")
yield* Metric.update(statusCodes, "200")
yield* Metric.update(statusCodes, "404")
const state = yield* Metric.value(statusCodes)
// state.occurrences is a Map: { "200" => 2, "404" => 1 }
yield* Effect.logInfo("status code counts", {
counts: Object.fromEntries(state.occurrences)
})
})

Attributes (Prometheus calls them labels) split a metric into independent series by dimension — per route, per region, per status. Set fixed attributes when declaring the metric, or derive a labelled view with Metric.withAttributes.

import { Effect, Metric } from "effect"
// Fixed attributes shared by every update.
const dbQueries = Metric.counter("db_queries_total", {
attributes: { database: "primary" }
})
const recordQuery = Effect.fn("recordQuery")(function*(table: string) {
// Add a per-call attribute without redefining the metric.
yield* Metric.update(Metric.withAttributes(dbQueries, { table }), 1)
})

Metrics live in the in-process registry until you export them. The effect/unstable/observability package offers two paths.

PrometheusMetrics.layerHttp adds a GET /metrics route to an Effect HTTP router that serves the registry in Prometheus exposition format, ready to be scraped.

import { Layer } from "effect"
import { PrometheusMetrics } from "effect/unstable/observability"
// Serve metrics on /metrics for Prometheus to scrape.
const Metrics = PrometheusMetrics.layerHttp({ path: "/metrics" })
// Provide this alongside your HttpRouter layer.
export const MetricsLayer = Metrics

You can also format the registry to a string on demand with PrometheusMetrics.format.

OtlpMetrics.layer pushes the registry to an OTLP metrics endpoint on an interval. Like the tracer and logger exporters, it needs an OTLP serializer and an HttpClient.

import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpMetrics, OtlpSerialization } from "effect/unstable/observability"
export const MetricsLayer = OtlpMetrics.layer({
url: "http://localhost:4318/v1/metrics",
resource: { serviceName: "checkout-api", serviceVersion: "1.0.0" },
exportInterval: "10 seconds"
}).pipe(
Layer.provide(OtlpSerialization.layerJson),
Layer.provide(FetchHttpClient.layer)
)

The Metric.update/Metric.modify calls in the examples above are explicit: you decide where and when to feed a value into a metric. That is the right tool when the value you record is computed inside the effect. But the most common thing you want to measure is the outcome of an effect — did it succeed, did it fail, how long did it take — and threading that by hand (capturing the exit, timing with Effect.timed, remembering to record on every branch) is tedious and easy to get wrong.

The Effect.track* combinators wire an effect’s outcome into a metric for you. They are pipeable, leave the effect’s success/error channels untouched, and only record when their condition is met. Stack several of them onto one handler to get a full picture from a single .pipe:

import { Effect, Metric } from "effect"
// One metric per question we want to answer about the handler.
const ok = Metric.counter("checkout_success_total", {
description: "Successful checkouts"
}).pipe(Metric.withConstantInput(1)) // each success counts as 1
const failed = Metric.counter("checkout_failure_total", {
description: "Failed checkouts"
}).pipe(Metric.withConstantInput(1)) // each typed failure counts as 1
const latency = Metric.timer("checkout_duration", {
description: "Checkout handling latency"
})
declare const processCheckout: Effect.Effect<string, Error>
// A single pipe records the count of successes, the count of failures, and the
// elapsed duration — without touching the body of `processCheckout`.
const handler = processCheckout.pipe(
Effect.trackSuccesses(ok),
Effect.trackErrors(failed),
Effect.trackDuration(latency)
)

Records into a metric every time the effect completes, regardless of outcome. The two-argument form takes a metric typed over Exit<A, E> and feeds it the exit directly; the three-argument form takes a mapping function from the Exit to the metric’s input, letting you classify or extract a value first.

import { Effect, Exit, Metric } from "effect"
// Count every execution by giving the counter a constant input of 1.
const executions = Metric.counter("effect_executions").pipe(
Metric.withConstantInput(1)
)
const counted = Effect.succeed("hello").pipe(Effect.track(executions))
// running `counted` => state.count === 1
// Or classify the exit into a frequency metric with a mapping function.
const outcomes = Metric.frequency("effect_outcomes")
const classified = Effect.succeed("result").pipe(
Effect.track(outcomes, (exit: Exit.Exit<string, Error>) =>
Exit.isSuccess(exit) ? "success" : "failure")
)
// running `classified` => occurrences: { "success" => 1 }

Updates the metric only when the effect succeeds. With no function it feeds the success value straight in (the metric’s input must match A); with a mapping function it derives the input from the success value first.

import { Effect, Metric } from "effect"
const successes = Metric.counter("successes").pipe(
Metric.withConstantInput(1)
)
const a = Effect.succeed(42).pipe(Effect.trackSuccesses(successes))
// running `a` => { count: 1, incremental: false }
// Map the success value into the metric's input — here, record its length.
const requestSize = Metric.gauge("request_size_bytes")
const b = Effect.succeed("Hello World!").pipe(
Effect.trackSuccesses(requestSize, (value) => value.length)
)
// running `b` => { value: 12 }

Updates the metric only on a typed failure (the E channel) — defects are not recorded. Without a function it feeds the error value in directly; with a function it maps the error first, e.g. into a status string for a frequency.

import { Data, Effect, Metric } from "effect"
class ConnectionFailedError extends Data.TaggedError("ConnectionFailedError")<{}> {}
// Bucket failures by their tag using a frequency metric.
const errorTypes = Metric.frequency("error_types")
const program = Effect.fail(new ConnectionFailedError()).pipe(
Effect.trackErrors(errorTypes, (error) => error._tag)
)
// running `program` => occurrences: { "ConnectionFailedError" => 1 }

Updates the metric only on a defect (an unexpected, untyped failure raised by Effect.die or a thrown exception). The optional function maps the defect — of type unknown — into the metric’s input.

import { Effect, Metric } from "effect"
const defects = Metric.counter("defects").pipe(Metric.withConstantInput(1))
const program = Effect.die("Critical system failure").pipe(
Effect.trackDefects(defects)
)
// running `program` => { count: 1, incremental: false }

Records how long the effect took to run. By default it feeds a Duration into the metric (use a Metric.timer, which accepts a Duration); the optional function converts the Duration first, e.g. Duration.toMillis for a gauge.

import { Duration, Effect, Metric } from "effect"
// A timer accepts a Duration directly.
const executionTimer = Metric.timer("execution_time")
const timed = Effect.sleep("100 millis").pipe(
Effect.trackDuration(executionTimer)
)
// running `timed` => { count: 1, min: ~100ms, max: ~100ms, sum: ~100ms }
// Or convert the Duration to a number before recording it.
const millis = Metric.gauge("execution_millis")
const asMillis = Effect.sleep("200 millis").pipe(
Effect.trackDuration(millis, (duration) => Duration.toMillis(duration))
)
// running `asMillis` => { value: 200 }