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))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.