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.

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
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
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
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:
# 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"} 40Bind /metrics on an internal port in production (a second listener), or restrict it by network policy.
Prometheus config
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:
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.


