use axum::{
extract::{MatchedPath, Request, State},
middleware::Next,
response::Response,
};
use prometheus::{
Histogram, HistogramOpts, HistogramVec, IntCounterVec, IntGauge, IntGaugeVec, Opts, Registry,
};
macro_rules! metric {
($suffix:literal) => {
concat!("imcp2_", $suffix)
};
}
const UNMATCHED_ROUTE: &str = "other";
const UNKNOWN_METHOD: &str = "other";
const KNOWN_METHODS: [&str; 9] = [
"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT",
];
const SESSION_LABEL: &str = "ii_instance";
const LATENCY_BUCKETS: &[f64] = &[
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0,
];
#[derive(Clone)]
pub struct Metrics {
requests: IntCounterVec,
duration: HistogramVec,
live_sessions: IntGaugeVec,
active_sessions: IntGaugeVec,
scrapes: Histogram,
servers: Vec<crate::McpServer>,
}
impl Metrics {
pub fn new(
registry: &Registry,
version: &str,
commit: &str,
started_at: u64,
servers: &[&crate::McpServer],
) -> prometheus::Result<Self> {
let requests = IntCounterVec::new(
Opts::new(
metric!("http_requests_total"),
"Total HTTP requests, by matched route template, method and status code.",
),
&["route", "method", "status"],
)?;
registry.register(Box::new(requests.clone()))?;
let duration = HistogramVec::new(
HistogramOpts::new(
metric!("http_request_duration_seconds"),
"HTTP request latency in seconds, by matched route template and method.",
)
.buckets(LATENCY_BUCKETS.to_vec()),
&["route", "method"],
)?;
registry.register(Box::new(duration.clone()))?;
let live_sessions = IntGaugeVec::new(
Opts::new(
metric!("live_sessions"),
"Authenticated sessions holding a currently-valid Internet Identity grant, \
per instance. A session counts from grant redemption until expiry, idle or not.",
),
&[SESSION_LABEL],
)?;
registry.register(Box::new(live_sessions.clone()))?;
let active_sessions = IntGaugeVec::new(
Opts::new(
metric!("active_sessions"),
"Live sessions that also made a request within the activity window (~15 min). \
Sampled independently of imcp2_live_sessions, so a scrape may briefly \
observe it above live.",
),
&[SESSION_LABEL],
)?;
registry.register(Box::new(active_sessions.clone()))?;
let scrapes = Histogram::with_opts(
HistogramOpts::new(
metric!("metrics_scrape_duration_seconds"),
"Time spent producing this endpoint's own response: refreshing the derived \
gauges, then gathering and encoding the registry.",
)
.buckets(vec![0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5]),
)?;
registry.register(Box::new(scrapes.clone()))?;
let build_info = IntGaugeVec::new(
Opts::new(
metric!("build_info"),
"Always 1. Carries the running version and commit as labels.",
),
&["version", "commit"],
)?;
registry.register(Box::new(build_info.clone()))?;
build_info.with_label_values(&[version, commit]).set(1);
let start_time = IntGauge::new(
metric!("process_start_time_seconds"),
"Unix epoch seconds at which this process started, i.e. when the deployment \
last restarted. Every deploy restarts the service.",
)?;
registry.register(Box::new(start_time.clone()))?;
start_time.set(started_at as i64);
for server in servers {
let name = server.instance().name;
live_sessions.with_label_values(&[name]).set(0);
active_sessions.with_label_values(&[name]).set(0);
}
Ok(Self {
requests,
duration,
live_sessions,
active_sessions,
scrapes,
servers: servers.iter().map(|s| (*s).clone()).collect(),
})
}
pub(crate) fn observe_request(&self, route: &str, method: &str, status: u16, elapsed: f64) {
let method = method_label(method);
let status = status.to_string();
self.requests.with_label_values(&[route, method, &status]).inc();
self.duration.with_label_values(&[route, method]).observe(elapsed);
}
pub async fn refresh(&self) {
for server in &self.servers {
let g = server.session_gauges().await;
let name = server.instance().name;
self.live_sessions.with_label_values(&[name]).set(g.live as i64);
self.active_sessions.with_label_values(&[name]).set(g.active as i64);
}
}
pub fn observe_scrape(&self, seconds: f64) {
self.scrapes.observe(seconds);
}
}
pub fn register_process_collector(registry: &Registry) -> prometheus::Result<()> {
#[cfg(target_os = "linux")]
registry.register(Box::new(
prometheus::process_collector::ProcessCollector::for_self(),
))?;
#[cfg(not(target_os = "linux"))]
let _ = registry;
Ok(())
}
pub async fn write_request_metrics(
State(metrics): State<Metrics>,
req: Request,
next: Next,
) -> Response {
let route = req
.extensions()
.get::<MatchedPath>()
.map(|m| m.as_str().to_string());
let method = req.method().clone();
let started = std::time::Instant::now();
let resp = next.run(req).await;
metrics.observe_request(
route_label(route.as_deref()),
method_label(method.as_str()),
resp.status().as_u16(),
started.elapsed().as_secs_f64(),
);
resp
}
pub async fn write_request_logs(req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
let started = std::time::Instant::now();
let resp = next.run(req).await;
let status = resp.status().as_u16();
let elapsed_ms = started.elapsed().as_millis() as u64;
tracing::debug!(%method, %path, status, elapsed_ms, "http request");
resp
}
pub fn route_label(matched: Option<&str>) -> &str {
match matched {
Some(t) if !t.is_empty() => t,
_ => UNMATCHED_ROUTE,
}
}
pub fn method_label(method: &str) -> &'static str {
match KNOWN_METHODS.iter().position(|m| *m == method) {
Some(i) => KNOWN_METHODS[i],
None => UNKNOWN_METHOD,
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::{body::Body, http::Request as HttpRequest, routing::get, Router};
use prometheus::{Encoder, TextEncoder};
use tower::ServiceExt;
fn encode(registry: &Registry) -> String {
let mut buf = Vec::new();
TextEncoder::new().encode(®istry.gather(), &mut buf).unwrap();
String::from_utf8(buf).unwrap()
}
fn fixture() -> (Registry, Metrics) {
let r = Registry::new();
let m = Metrics::new(&r, "1.2.3", "abc1234", 1_700_000_000, &[]).unwrap();
(r, m)
}
fn server() -> crate::McpServer {
crate::McpServer::new(crate::McpConfig {
agent: crate::Agent::builder()
.with_url(crate::IC_URL)
.build()
.expect("build agent"),
instance: crate::IiInstance::prod().expect("prod instance"),
public_url: "https://mcp.example.com".into(),
mcp_path: "/mcp".into(),
clients: crate::SharedClients::load(std::env::temp_dir()),
state_dir: std::env::temp_dir(),
require_resource: true,
})
}
fn app(m: Metrics) -> Router {
Router::new()
.route("/version", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(m, write_request_metrics))
}
fn request_series(out: &str) -> Vec<&str> {
out.lines()
.filter(|l| l.starts_with(metric!("http_requests_total")) && l.contains('{'))
.collect()
}
#[test]
fn unmatched_requests_share_one_label() {
assert_eq!(route_label(None), UNMATCHED_ROUTE);
assert_eq!(route_label(Some("")), UNMATCHED_ROUTE);
}
#[test]
fn matched_requests_keep_their_template() {
assert_eq!(route_label(Some("/version")), "/version");
}
#[test]
fn standard_methods_pass_through_and_the_rest_collapse() {
for m in KNOWN_METHODS {
assert_eq!(method_label(m), m);
}
for m in ["WIBBLE", "get", "", "GET ", "X-CUSTOM"] {
assert_eq!(method_label(m), UNKNOWN_METHOD, "{m:?} should collapse");
}
}
#[test]
fn collectors_land_in_the_callers_registry() {
let (r, _m) = fixture();
let out = encode(&r);
assert!(out.contains(metric!("build_info")), "{out}");
assert!(out.contains(r#"version="1.2.3""#), "{out}");
assert!(out.contains(r#"commit="abc1234""#), "{out}");
assert!(
out.contains(concat!(metric!("process_start_time_seconds"), " 1700000000")),
"{out}"
);
}
#[test]
fn new_does_not_register_the_process_collector() {
let (r, _m) = fixture();
assert!(
!encode(&r).contains("process_cpu_seconds_total"),
"Metrics::new must not register process_* series"
);
register_process_collector(&r).unwrap();
#[cfg(target_os = "linux")]
assert!(encode(&r).contains("process_cpu_seconds_total"));
}
#[test]
fn double_registration_is_an_error_not_a_panic() {
let (r, _m) = fixture();
match Metrics::new(&r, "1.2.3", "abc1234", 0, &[]) {
Err(prometheus::Error::AlreadyReg) => {}
Err(e) => panic!("expected AlreadyReg, got {e:?}"),
Ok(_) => panic!("expected the second registration to fail"),
}
}
#[test]
fn records_requests_and_sessions() {
let (r, m) = fixture();
m.observe_request("/version", "GET", 200, 0.002);
m.observe_request("/version", "GET", 200, 0.003);
m.live_sessions.with_label_values(&["prod"]).set(7);
m.active_sessions.with_label_values(&["prod"]).set(3);
let out = encode(&r);
assert!(
out.contains(concat!(
metric!("http_requests_total"),
r#"{method="GET",route="/version",status="200"} 2"#
)),
"{out}"
);
assert!(
out.contains(concat!(metric!("live_sessions"), r#"{ii_instance="prod"} 7"#)),
"{out}"
);
assert!(
out.contains(concat!(metric!("active_sessions"), r#"{ii_instance="prod"} 3"#)),
"{out}"
);
}
#[test]
fn scrape_duration_is_recordable_by_the_host() {
let (r, m) = fixture();
m.observe_scrape(0.004);
assert!(
encode(&r).contains(concat!(metric!("metrics_scrape_duration_seconds"), "_count 1")),
"{}",
encode(&r)
);
}
#[tokio::test]
async fn a_flood_of_distinct_paths_yields_one_series() {
let (r, m) = fixture();
for i in 0..200 {
let req = HttpRequest::builder()
.uri(format!("/scan-{i}-{}", "x".repeat(i % 13)))
.body(Body::empty())
.unwrap();
app(m.clone()).oneshot(req).await.unwrap();
}
let out = encode(&r);
let series = request_series(&out);
assert_eq!(series.len(), 1, "expected one series, got:\n{out}");
assert!(series[0].contains(r#"route="other""#), "{}", series[0]);
assert!(series[0].ends_with(" 200"), "{}", series[0]);
}
#[tokio::test]
async fn a_flood_of_extension_methods_yields_one_series() {
let (r, m) = fixture();
for i in 0..100 {
let req = HttpRequest::builder()
.method(format!("WIBBLE{i}").as_str())
.uri("/version")
.body(Body::empty())
.unwrap();
app(m.clone()).oneshot(req).await.unwrap();
}
let out = encode(&r);
let series = request_series(&out);
assert_eq!(series.len(), 1, "expected one series, got:\n{out}");
assert!(series[0].contains(r#"method="other""#), "{}", series[0]);
let buckets = out
.lines()
.filter(|l| l.starts_with(concat!(metric!("http_request_duration_seconds"), "_bucket")))
.count();
assert_eq!(buckets, LATENCY_BUCKETS.len() + 1, "{out}");
}
#[tokio::test]
async fn real_routes_keep_their_identity() {
let (r, m) = fixture();
let req = HttpRequest::builder().uri("/version").body(Body::empty()).unwrap();
app(m.clone()).oneshot(req).await.unwrap();
assert!(
encode(&r).contains(concat!(
metric!("http_requests_total"),
r#"{method="GET",route="/version",status="200"} 1"#
)),
"{}",
encode(&r)
);
}
#[test]
fn construction_zero_fills_served_instances() {
let r = Registry::new();
let _m = Metrics::new(&r, "1.2.3", "abc1234", 0, &[&server()]).unwrap();
let out = encode(&r);
assert!(
out.contains(concat!(metric!("live_sessions"), r#"{ii_instance="prod"} 0"#)),
"{out}"
);
assert!(
out.contains(concat!(metric!("active_sessions"), r#"{ii_instance="prod"} 0"#)),
"{out}"
);
}
#[tokio::test]
async fn refresh_reads_through_to_a_server() {
let r = Registry::new();
let m = Metrics::new(&r, "1.2.3", "abc1234", 0, &[&server()]).unwrap();
m.live_sessions.with_label_values(&["prod"]).set(99);
m.active_sessions.with_label_values(&["prod"]).set(42);
m.refresh().await;
let out = encode(&r);
assert!(
out.contains(concat!(metric!("live_sessions"), r#"{ii_instance="prod"} 0"#)),
"{out}"
);
assert!(
out.contains(concat!(metric!("active_sessions"), r#"{ii_instance="prod"} 0"#)),
"{out}"
);
}
#[tokio::test]
async fn refresh_with_no_servers_is_a_noop() {
let (r, m) = fixture();
m.refresh().await;
assert!(!encode(&r).contains(concat!(metric!("live_sessions"), "{")));
}
}