Skip to content
rusthacks.dev
DevOps

Prometheus Metrics Exported From a Rust Service Step by Step

Expose Prometheus metrics from a Rust service: counters, gauges and histograms with the metrics crate, an Axum /metrics endpoint, labels to keep small and useful metrics to start with.

3 min read
Summarize in AI
Flat illustration for the post “Prometheus Metrics Exported From a Rust Service Step by Step”, composed around web.

Logs describe single events. Metrics describe rates and distributions, and they are cheap to keep for months. Prometheus pulls them from an HTTP endpoint. Everything compiles on Rust 1.90 with edition 2024, metrics 0.24, metrics-exporter-prometheus 0.16 (without its built-in HTTP listener), axum 0.8 and tokio 1.

Install the recorder

rust
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};

pub fn install_metrics() -> PrometheusHandle {
    PrometheusBuilder::new()
        .set_buckets_for_metric(
            metrics_exporter_prometheus::Matcher::Full("http_request_duration_seconds".into()),
            &[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0],
        )
        .expect("bucket config")
        .install_recorder()
        .expect("recorder installed once")
}

The recorder is global; install it once at startup. The returned handle renders the current values as text. Histogram buckets are chosen per metric: pick bounds around your latency target so percentile estimates are meaningful.

A middleware that records requests

rust
use axum::{extract::{MatchedPath, Request}, middleware::Next, response::Response};
use metrics::{counter, gauge, histogram};
use std::time::Instant;

pub async fn track(req: Request, next: Next) -> Response {
    let path = req
        .extensions()
        .get::<MatchedPath>()
        .map(|p| p.as_str().to_owned())
        .unwrap_or_else(|| "unmatched".to_owned());
    let method = req.method().to_string();

    gauge!("http_requests_in_flight").increment(1.0);
    let start = Instant::now();

    let res = next.run(req).await;

    let status = res.status().as_u16().to_string();
    gauge!("http_requests_in_flight").decrement(1.0);
    counter!("http_requests_total", "method" => method.clone(), "path" => path.clone(), "status" => status).increment(1);
    histogram!("http_request_duration_seconds", "method" => method, "path" => path)
        .record(start.elapsed().as_secs_f64());

    res
}

MatchedPath gives the route template (/users/{id}), so thousands of ids collapse into one label value. The unmatched case is labelled unmatched, not the raw URL, which attackers could vary endlessly. The middleware must run after routing, so apply it with route_layer on the router.

The /metrics route

rust
use axum::{extract::State, middleware, routing::get, Router};
use metrics_exporter_prometheus::PrometheusHandle;

async fn metrics(State(handle): State<PrometheusHandle>) -> String {
    handle.render()
}

pub fn app(handle: PrometheusHandle) -> Router {
    let api = Router::new()
        .route("/users/{id}", get(|| async { "user" }))
        .route_layer(middleware::from_fn(track));

    let internal = Router::new().route("/metrics", get(metrics)).with_state(handle);
    api.merge(internal)
}

A scrape returns text:

Text
# TYPE http_requests_total counter
http_requests_total{method="GET",path="/users/{id}",status="200"} 42
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",path="/users/{id}",le="0.05"} 40

Bind /metrics on an internal port in production (a second listener), or restrict it by network policy.

Prometheus config

prometheus.ymlYAML
scrape_configs:
  - job_name: app
    scrape_interval: 15s
    static_configs:
      - targets: ["app:9000"]

What to measure first

The RED method per service: **R**ate (requests per second), **E**rrors (5xx rate), **D**uration (latency percentiles). Add saturation signals: in-flight requests, database pool in use, queue depth, Tokio task counts. Useful queries:

Text
sum(rate(http_requests_total[5m])) by (path)
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, path))

Cardinality rules

  • Labels come from small closed sets: method, route template, status class, version.
  • Never label with user ids, emails, request ids, raw paths or error strings.
  • Check the series count: curl -s localhost:9000/metrics | wc -l. Growth with traffic means a bad label.

The dashboards built on these are in Grafana dashboards, and the latency concepts in latency versus throughput.

Also inWeb
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.