feat: add Prometheus metrics endpoint and query instrumentation

This commit is contained in:
Till Wegmueller 2026-04-03 19:45:20 +02:00
parent df3cc1eb91
commit 820a6410c4
No known key found for this signature in database
6 changed files with 70 additions and 6 deletions

View file

@ -1,11 +1,23 @@
use axum::extract::State;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::routing::get; use axum::routing::get;
use axum::Router; use axum::Router;
use sea_orm::{ConnectionTrait, Statement};
use crate::state::AppState; use crate::state::AppState;
async fn healthz() -> StatusCode { async fn healthz(State(state): State<AppState>) -> StatusCode {
StatusCode::OK match state
.db
.execute(Statement::from_string(
sea_orm::DatabaseBackend::Sqlite,
"SELECT 1".to_string(),
))
.await
{
Ok(_) => StatusCode::OK,
Err(_) => StatusCode::SERVICE_UNAVAILABLE,
}
} }
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {

13
src/handler/metrics.rs Normal file
View file

@ -0,0 +1,13 @@
use axum::extract::State;
use axum::routing::get;
use axum::Router;
use crate::state::AppState;
async fn metrics(State(state): State<AppState>) -> String {
state.metrics_handle.render()
}
pub fn router() -> Router<AppState> {
Router::new().route("/metrics", get(metrics))
}

View file

@ -2,6 +2,7 @@ pub mod domains;
mod health; mod health;
mod host_meta; mod host_meta;
pub mod links; pub mod links;
mod metrics;
pub mod tokens; pub mod tokens;
mod webfinger; mod webfinger;
@ -45,5 +46,6 @@ pub fn router(state: AppState) -> Router {
.merge(public_routes) .merge(public_routes)
.merge(api_routes) .merge(api_routes)
.merge(health::router()) .merge(health::router())
.merge(metrics::router())
.with_state(state) .with_state(state)
} }

View file

@ -35,15 +35,36 @@ async fn webfinger(
State(state): State<AppState>, State(state): State<AppState>,
uri: Uri, uri: Uri,
) -> AppResult<Response> { ) -> AppResult<Response> {
let start = std::time::Instant::now();
let (resource_opt, rels) = parse_webfinger_query(&uri); let (resource_opt, rels) = parse_webfinger_query(&uri);
let resource = resource_opt let resource = resource_opt
.ok_or_else(|| AppError::BadRequest("missing resource parameter".into()))?; .ok_or_else(|| AppError::BadRequest("missing resource parameter".into()))?;
let cached = state // Extract domain from resource for metrics labeling
.cache let resource_domain = resource
.get(&resource) .split('@')
.ok_or(AppError::NotFound)?; .nth(1)
.or_else(|| {
// Handle URI-style resources like https://domain/path
resource.split("://").nth(1).and_then(|s| s.split('/').next())
})
.unwrap_or("unknown")
.to_string();
let cached = match state.cache.get(&resource) {
Some(c) => {
metrics::counter!("webfinger_queries_total", "domain" => resource_domain.clone(), "status" => "hit").increment(1);
c
}
None => {
metrics::counter!("webfinger_queries_total", "domain" => resource_domain.clone(), "status" => "miss").increment(1);
let elapsed = start.elapsed().as_secs_f64();
metrics::histogram!("webfinger_query_duration_seconds").record(elapsed);
return Err(AppError::NotFound);
}
};
let links: Vec<serde_json::Value> = cached let links: Vec<serde_json::Value> = cached
.links .links
@ -94,6 +115,9 @@ async fn webfinger(
response_body.insert("links".into(), json!(links)); response_body.insert("links".into(), json!(links));
let elapsed = start.elapsed().as_secs_f64();
metrics::histogram!("webfinger_query_duration_seconds").record(elapsed);
Ok(( Ok((
[ [
(header::CONTENT_TYPE, "application/jrd+json"), (header::CONTENT_TYPE, "application/jrd+json"),

View file

@ -1,3 +1,4 @@
use metrics_exporter_prometheus::PrometheusHandle;
use sea_orm::DatabaseConnection; use sea_orm::DatabaseConnection;
use std::sync::Arc; use std::sync::Arc;
@ -11,4 +12,5 @@ pub struct AppState {
pub cache: Cache, pub cache: Cache,
pub settings: Arc<Settings>, pub settings: Arc<Settings>,
pub challenge_verifier: Arc<dyn ChallengeVerifier>, pub challenge_verifier: Arc<dyn ChallengeVerifier>,
pub metrics_handle: PrometheusHandle,
} }

View file

@ -1,3 +1,4 @@
use metrics_exporter_prometheus::PrometheusBuilder;
use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, Statement}; use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, Statement};
use sea_orm_migration::MigratorTrait; use sea_orm_migration::MigratorTrait;
use std::sync::Arc; use std::sync::Arc;
@ -57,10 +58,20 @@ pub async fn test_state_with_settings(settings: Settings) -> AppState {
let db = setup_test_db().await; let db = setup_test_db().await;
let cache = Cache::new(); let cache = Cache::new();
cache.hydrate(&db).await.unwrap(); cache.hydrate(&db).await.unwrap();
// Each test gets its own metrics recorder. If install_recorder fails because
// another test already installed one, build a standalone recorder and grab its handle.
let metrics_handle = PrometheusBuilder::new()
.install_recorder()
.unwrap_or_else(|_| {
let recorder = PrometheusBuilder::new().build_recorder();
recorder.handle()
});
AppState { AppState {
db, db,
cache, cache,
settings: Arc::new(settings), settings: Arc::new(settings),
challenge_verifier: Arc::new(webfingerd::challenge::MockChallengeVerifier), challenge_verifier: Arc::new(webfingerd::challenge::MockChallengeVerifier),
metrics_handle,
} }
} }