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.
Counters
Section titled “Counters”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.
Gauges
Section titled “Gauges”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 })})Histograms
Section titled “Histograms”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.
Summaries
Section titled “Summaries”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)})Frequencies
Section titled “Frequencies”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 (labels)
Section titled “Attributes (labels)”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)})Exporting metrics
Section titled “Exporting metrics”Metrics live in the in-process registry until you export them. The
effect/unstable/observability package offers two paths.
Prometheus
Section titled “Prometheus”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 = MetricsYou 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))Tracking effects with metrics
Section titled “Tracking effects with metrics”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))Effect.track
Section titled “Effect.track”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 }Effect.trackSuccesses
Section titled “Effect.trackSuccesses”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 }Effect.trackErrors
Section titled “Effect.trackErrors”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 }Effect.trackDefects
Section titled “Effect.trackDefects”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 }Effect.trackDuration
Section titled “Effect.trackDuration”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 }Related
Section titled “Related”- Logging — structured event logs.
- Tracing — distributed traces and OTLP export.
- Services & Layers — provide exporters as layers.
- Http API — serve a Prometheus endpoint from your app.