Autumn's /actuator/prometheus and /actuator/metrics endpoints already expose the framework's own autumn_http_* families. The autumn_web::metrics facade lets application code add its own counters, gauges and timers at the point where the interesting thing happens — one line at the call site, no trait to implement, no type to define, nothing to register with AppBuilder.

Everything recorded through the facade appears automatically on the same scrape endpoint as the built-in families.

Contributing families from a plugin or subsystem — with your own storage, collected on demand at scrape time — is the MetricsSource trait instead. See Plugin Metrics Sources, and the comparison table below.


Quick start

1. Record from a handler

Rust
use autumn_web::metrics;
use autumn_web::get;

#[get("/checkout")]
async fn checkout() -> &'static str {
    metrics::counter("checkout_completed_total")
        .with_label("status", "paid")
        .increment(1);
    "ok"
}

That is the whole integration. No AppBuilder call, no app-defined struct: the first counter("checkout_completed_total") call registers the instrument in a process-global registry and every later call finds it again.

2. Scrape it

Code
$ curl http://localhost:3000/actuator/prometheus
# HELP autumn_http_requests_total Total number of HTTP requests
# TYPE autumn_http_requests_total counter
autumn_http_requests_total{version="stable"} 1234
...
# TYPE checkout_completed_total counter
checkout_completed_total{status="paid"} 3

3. Describe it (optional)

Without a description there is no # HELP line. Call describe_counter once at startup to add one:

Rust
autumn_web::metrics::describe_counter(
    "checkout_completed_total",
    "Checkouts that reached a terminal state",
);
Code
# HELP checkout_completed_total Checkouts that reached a terminal state
# TYPE checkout_completed_total counter
checkout_completed_total{status="paid"} 3

describe_gauge and describe_histogram do the same for the other kinds.

Describing a metric does not register it — the description is held until the first counter(...)/gauge(...)/histogram(...) call creates the instrument. So the two calls may come in either order, and a metric that is described but never recorded stays out of the scrape output entirely. (It also means describe_histogram cannot accidentally freeze a histogram's bucket bounds; see Buckets.)

Help text is stripped of control characters — a # HELP line is one line — and truncated to 512 characters.


Which instrument do I want?

InstrumentAnswersConvention
counterhow many times did X happen?*_total
gaugehow many X are there right now?plain noun
timer (and histogram)how long did X take?*_seconds

A counter only ever goes up, so PromQL rate() works on it. A gauge moves in both directions. A timer is a histogram of seconds, which is what histogram_quantile() needs to give you a p99.

Rust
use autumn_web::metrics;

// Counter: increments only.
metrics::counter("emails_sent_total").increment(1);

// Gauge: set, increment, decrement.
metrics::gauge("worker_queue_depth").set(queue.len()); // usize, u64, i64, f64…
metrics::gauge("worker_queue_depth").increment(1);
metrics::gauge("worker_queue_depth").decrement(1);

// Histogram: any non-negative observation (not just seconds).
metrics::histogram("upload_size_bytes").record(2_048);

Gauges and histograms take any primitive number, including usize, u64 and i64 — so set(queue.len()) compiles without a cast. Values above 2^53 are rounded to the nearest f64, which is all Prometheus can carry anyway.

Counters saturate at u64::MAX instead of wrapping: a wrapped total is indistinguishable from a counter reset and would give rate() an enormous phantom spike. Non-finite values (NaN, ±Inf) are rejected on gauges and histograms alike, so the scrape and the JSON view can never disagree.


Timing with timer

The guard returned by start() records the elapsed time when it drops, so every exit path is covered — including an early ? return and an unwinding panic:

Rust
use autumn_web::metrics;

async fn charge_card(amount: u64) -> Result<(), PaymentError> {
    let _timing = metrics::timer("payment_duration_seconds").start();

    let receipt = gateway::charge(amount).await?; // early return: still recorded
    ledger::write(receipt).await?;
    Ok(())
}

Bind the guard to a named variable (_timing, not _): let _ = ... drops it immediately and records a duration of roughly zero.

Three more ways to record, when a guard does not fit:

Rust
use std::time::Duration;
use autumn_web::metrics;

let timer = metrics::timer("payment_duration_seconds");

// Wrap a closure, or a future.
let receipt = timer.time(|| gateway::charge_blocking(amount));
let receipt = timer.time_async(gateway::charge(amount)).await;

// Or record a duration you already measured.
timer.record(Duration::from_millis(120));

A guard can also be resolved early. stop() records and returns the elapsed duration; cancel() throws the measurement away and records nothing:

Rust
let timing = metrics::timer("payment_duration_seconds").start();
let elapsed = timing.stop();          // records once, returns the duration
// let timing = ...; timing.cancel(); // records nothing

A guard records exactly once, whether it is stopped explicitly or dropped.

Buckets

Timers and histograms use these default upper bounds, in seconds:

Code
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10

Override them for one instrument at startup, before it is first used — bounds are frozen at registration so they cannot move under a running scrape target:

Rust
use autumn_web::metrics;

// Either order works: describing a metric does not register it.
metrics::describe_histogram("payment_duration_seconds", "Card charge latency");
metrics::set_histogram_buckets(
    "payment_duration_seconds",
    &[0.05, 0.1, 0.5, 1.0, 5.0, 30.0],
);

Bounds must be 1..=20 finite, positive, strictly ascending values; anything else is ignored with a warning and the defaults are kept. A call that arrives after the histogram has been registered is ignored with a warning too.

Bounds are rendered into le exactly as client_golang renders them, so a recording rule that matches le as a string keeps working: plain decimal inside [1e-4, 1e6) and exponential outside it (5e-05, 1e+06, 1e+21).

A timer renders as a standard Prometheus histogram — cumulative _bucket lines ending at le="+Inf", which always equals _count:

Code
# TYPE payment_duration_seconds histogram
payment_duration_seconds_bucket{le="0.05"} 0
payment_duration_seconds_bucket{le="0.1"} 2
payment_duration_seconds_bucket{le="0.5"} 3
payment_duration_seconds_bucket{le="1"} 3
payment_duration_seconds_bucket{le="5"} 4
payment_duration_seconds_bucket{le="30"} 4
payment_duration_seconds_bucket{le="+Inf"} 4
payment_duration_seconds_sum 1.732
payment_duration_seconds_count 4

Non-finite and negative observations are rejected with a warning, so _sum can never become NaN. _sum is still an f64 accumulator that is never reset; a long-lived process recording astronomically large observations saturates it at f64::MAX rather than overflowing to +Inf, and gauge adjustments saturate the same way. Recording seconds (what timer does) keeps you many orders of magnitude away from that.


Labels and cardinality

with_label attaches a label to the series a handle records into. Labels are canonicalized for you: sorted by key, deduplicated first-wins, values stripped of control characters (a stray \n or ANSI escape cannot split or forge an exposition line) and truncated to 128 characters. An invalid, over-long or reserved label name (le, quantile, anything starting __) drops that label, never the sample.

When a handle carries more than eight usable labels, the ones kept are those with the lexicographically smallest names — never the first eight you happened to attach — so the same label set always lands in the same series.

Rust
metrics::counter("checkout_completed_total")
    .with_label("status", "paid")
    .with_label("currency", "eur")
    .increment(1);

Canonicalizing happens as each label is attached, so a handle you keep is already in its final shape. The first sample for a label set registers its series, which allocates once. After that, recording through a held handle finds the series without allocating. On a hot loop with a fixed label set, build the handle once and reuse it. The per-call form above also works, but pays to build the label strings on every call.

Label values must come from a small, closed set the code controls. Never label with user input, email addresses, account or order IDs, or anything else unbounded. Every distinct combination of label values is a separate time series that lives for the life of the process — and a label value is written in plaintext to whatever scrapes you. The same rule (and the same reasoning) as structured log fields: see Logging: levels, format & PII.

The facade enforces hard caps so a mistake degrades the metric instead of the process. The three that bound cardinality are configurable; the rest are fixed:

LimitDefaultConfigurableOn overflow
Labeled series per instrument100[metrics] max_series_per_metricSamples with a new label set dropped and counted
Instruments in the registry256[metrics] max_instrumentsFurther new names get an inert handle
Labels per series8[metrics] max_labels_per_seriesExtra labels dropped, sample still recorded
Label value length128 charactersnoTruncated
Metric name length128 bytesnoRejected — inert handle, never truncated
Label name length128 bytesnoLabel dropped, sample still recorded
Help text length512 charactersnoTruncated

The unlabeled series — what a handle with no with_label call records into — is separate and does not count against the 100. Names are rejected rather than truncated, because two names sharing a 128-byte prefix would otherwise silently become one metric.

Raising a cap

The line between "a label-cardinality mistake" and "a large but deliberate label space" is a property of the app, not of the framework, so the three cardinality caps come from autumn.toml:

TOML
[metrics]
max_series_per_metric = 500
max_instruments = 512
max_labels_per_series = 12

Each key also takes the usual environment override — AUTUMN_METRICS__MAX_SERIES_PER_METRIC, AUTUMN_METRICS__MAX_INSTRUMENTS, AUTUMN_METRICS__MAX_LABELS_PER_SERIES. An out-of-range value (0, or above the ceiling of 100 000 / 100 000 / 64) fails the boot and autumn check, naming the key — it is not clamped, because a cap of 0 would silently drop every labeled sample the app records.

Two things to know before raising one:

  • A retained series is never evicted, so max_series_per_metric bounds permanent memory. Raise it because the app's label space is genuinely larger — a hundred routes, say — not to quiet autumn_metrics_series_dropped_total. If the label value is a user id, a URL, or an error string, the cap is not the problem.
  • max_labels_per_series changes series identity. The same twelve labels canonicalize to a different key under a cap of 8 than under 12, so changing it splits an instrument's history in two. Set it before the app ships, not in response to a graph.

The caps that are not configurable are the ones protecting the exposition format rather than memory: name, label-value and help-text lengths, and bucket count. Raising those would not let an app express anything new — only emit a scrape body a stricter parser may reject.

[metrics] is applied by AppBuilder::run, before it builds anything. The effective values are readable at runtime through autumn_web::metrics::max_series_per_metric() and its two siblings, and appear on /actuator/configprops.

Code in main that runs ahead of .run() therefore sees the defaults. describe_* and set_histogram_buckets are unaffected — they only stash, and their staging areas are bounded generously rather than by the running cap precisely so the startup pattern above keeps working when you raise max_instruments. But a metric actually recorded before .run() registers under the default 256, and an inert handle handed out then stays inert.

If your app records more than 256 distinct metric names before its own run, you need both: call metrics::set_limits before recording, and set the same (or a larger) max_instruments in [metrics]. run installs the configured section unconditionally — autumn.toml is the authority — so setting only the former puts the default back at startup, and every metric name registered after that is refused against a registry already over the restored cap.

Hitting the series cap logs one warning per instrument and is visible in the scrape itself, so you can alert on it:

Code
# HELP autumn_metrics_series_dropped_total App metric samples dropped because the metric had already hit its series cardinality cap
# TYPE autumn_metrics_series_dropped_total counter
autumn_metrics_series_dropped_total{metric="checkout_completed_total"} 57

That counts samples, not distinct label sets: a hot call site hammering one over-cap label set is exactly what you need to see, and counting distinct sets would mean remembering the very label sets the cap exists to stop remembering.

Series are never evicted once retained — evicting a counter would reset it and break rate().


Reserved names

  • The autumn_ prefix belongs to the framework's built-in families, as do the built-in names themselves. Registering one is rejected.
  • : is rejected too — Prometheus reserves it for recording rules.
  • A name that is not a valid Prometheus metric name is rejected.
  • A histogram (or timer) also reserves its derived _bucket, _sum and _count names. Registering an instrument that collides with another instrument's base or derived names is rejected, in either direction.
  • The first registration of a name fixes its kind. Asking for the same name as a different kind is rejected, so there is always exactly one # TYPE line per family.

Every rejection logs one warning and returns an inert handle that records nothing. The facade never panics and never poisons the scrape output. Rejected names are escaped and truncated before they reach the log, so a name carrying newlines or ANSI escapes cannot forge log records.

Registration order can lock you out, and the only signal is a log line. The derived-name reservation runs in both directions and first registration wins, so a gauge named payment_duration_seconds_sum registered anywhere in the process permanently blocks the histogram payment_duration_seconds — and the block is silent apart from one warning at the losing call site. Pick _sum/_count/_bucket-suffixed names only when you mean the derived families of a histogram you own.

Unlike the built-in families, app metrics carry no implicit version label. The label set belongs entirely to your call site, so the framework cannot collide with a version label you chose yourself.


JSON endpoint (/actuator/metrics)

The same data appears under a new top-level app key, alongside the existing http and database keys (which are unchanged):

Json
{
  "http": { "requests_total": 1234, ... },
  "app": [
    {
      "name": "checkout_completed_total",
      "help": "Checkouts that reached a terminal state",
      "kind": "counter",
      "series": [
        { "labels": { "status": "paid" }, "value": { "value": 3 } }
      ],
      "dropped_series": 0
    }
  ]
}

The key is absent — rather than an empty array — when the app has recorded nothing. Histogram series carry count, sum and cumulative buckets instead of a single value.

autumn_web::metrics::snapshot() returns the same view as Rust values, if you want to assert on recorded metrics from a test.


Actuator exposure config

Recording always works. Only exposure is gated.

TOML
[actuator]
prometheus = true    # /actuator/prometheus scrape endpoint — on by default

With prometheus = false the scrape endpoint is not mounted at all (404), for app metrics exactly as for built-in ones — there is no bypass. Only the Prometheus scrape format is gated: your call sites keep recording, the router says so once at startup, and /actuator/metrics still shows the app key, under the same visibility rules as the built-in families. See Plugin Metrics Sources for how this interacts with actuator.sensitive.


Facade vs MetricsSource: which one do I want?

Row labelautumn_web::metrics facadeMetricsSource trait
Use whenapp code should record an event as it happensa subsystem already owns the numbers
Modelpush: you call increment/set/recordpull: collect() runs at scrape time
Setupnone — call itimplement the trait, register with AppBuilder
Storagethe facade's process-global registryyours
Histograms / timersyesno — counters and gauges only
Registrationautomatic on first useexplicit, named
JSON keyappsources.<name>
Scopeprocess-globalper app instance
Typical callerhandlers, services, jobsplugins, framework subsystems

Both render into the same /actuator/prometheus output. Name ownership runs built-in families first, then facade metrics, then plugin sources: a plugin family colliding with a name already emitted is skipped with a warning.


A worked example

examples/bookmarks records one counter and one timer through this facade: bookmarks_created_total{outcome} in the create handler and bookmark_stats_query_seconds around the two grouped aggregates behind /bookmarks/stats. src/metrics.rs holds the names, the describe() call main makes at startup, and the assertions against both actuator endpoints; src/routes/bookmarks.rs holds the one-line call sites.

Testing

The registry is process-global and tests run concurrently, so give each test its own instrument names rather than clearing shared state:

Rust
// Behind the `test-support` feature.
let name = autumn_web::metrics::testing::unique_name("checkout_completed_total");
autumn_web::metrics::counter(&name).increment(1);

Assert with contains() on those unique names — never on whole-body equality or line counts of the scrape output, which also carries built-in families and anything a concurrent test recorded.

Those names are never reclaimed and they share the process-wide instrument budget (256 by default, [metrics] max_instruments) with every other test in the same binary. A handful per test is fine; a loop that registers hundreds will exhaust the registry for whatever runs after it. Cap the loop, or reuse one name with different labels — that cap is per-instrument.