Commit Diff


commit - 901713323c305809dbcf20c2d33505e2c65ea5eb
commit + 87e1be84a219cdfabd7d13aa98d7fd66b0d05704
blob - c43f0bf66015a2a1b7313fd4bb9e8584761a7fe0
blob + 4d421e1712dc62ac0ea7b179ab8e26577ac21756
--- web/template/Cargo.toml
+++ web/template/Cargo.toml
@@ -9,9 +9,12 @@ edition = "2024"
 [dependencies]
 anyhow = "=1.0.100"
 axum = "=0.8.6"
+metrics = { version = "=0.24.2", default-features = false }
+metrics-exporter-prometheus = { version = "=0.17.2", default-features = false }
 minijinja = "=2.12.0"
 serde = { version = "=1.0.228", features = ["derive"] }
 tokio = { version = "=1.48.0", features = ["macros", "rt-multi-thread", "signal"] }
 tower-http = { version = "=0.6.6", features = ["timeout", "trace", "fs", "request-id"] }
 tracing = "=0.1.41"
 tracing-subscriber = { version = "=0.3.20", features = ["env-filter"] }
+
blob - 20a1974ddf614cffd2d2a597cde1e84df5850396
blob + ba99019fcec86494e72815521c26c3c910d9ddf0
--- web/template/src/main.rs
+++ web/template/src/main.rs
@@ -21,6 +21,7 @@ use tokio::net::TcpListener;
 use tracing::info;
 
 mod helpers;
+mod metric;
 mod router;
 mod state;
 
@@ -28,6 +29,12 @@ mod state;
 async fn main() -> anyhow::Result<()> {
     helpers::init_tracing();
 
+    let (_main_server, _metrics_server) =
+        tokio::join!(start_main_server(), metric::start_metrics_server());
+    Ok(())
+}
+
+async fn start_main_server() -> anyhow::Result<()> {
     let mut env = Environment::new();
     env.add_template("layout", include_str!("../templates/layout.jinja"))?;
     env.add_template("home", include_str!("../templates/home.jinja"))?;
@@ -44,6 +51,5 @@ async fn main() -> anyhow::Result<()> {
     axum::serve(listener, app)
         .with_graceful_shutdown(helpers::shutdown_signal())
         .await?;
-
     Ok(())
 }
blob - d3bc57c7a8ad936ba925a369a467edd353acadea
blob + 1e002169f79037057ca3631f69e2749b83251b4b
--- web/template/src/router.rs
+++ web/template/src/router.rs
@@ -19,6 +19,7 @@ use axum::{
     Router,
     extract::State,
     http::{HeaderName, Request, StatusCode},
+    middleware,
     response::{Html, IntoResponse},
     routing::get,
 };
@@ -33,6 +34,7 @@ use tower_http::{
 };
 use tracing::{error, info_span};
 
+use crate::metric::track_metrics;
 use crate::state::AppState;
 
 const REQUEST_ID_HEADER: &str = "x-request-id";
@@ -49,26 +51,27 @@ pub(crate) fn route(app_state: Arc<AppState>) -> Route
         .layer((
             SetRequestIdLayer::new(x_request_id.clone(), MakeRequestUuid),
             TraceLayer::new_for_http().make_span_with(
-            |request: &Request<_>| {
-                // Log the request id as generated.
-                let request_id = request.headers().get(REQUEST_ID_HEADER);
+                |request: &Request<_>| {
+                    // Log the request id as generated.
+                    let request_id = request.headers().get(REQUEST_ID_HEADER);
 
-                match request_id {
-                    Some(request_id) => info_span!(
-                        "http_request",
-                        request_id = ?request_id,
-                    ),
-                    None => {
-                        error!("could not extract request_id");
-                        info_span!("http_request")
+                    match request_id {
+                        Some(request_id) => info_span!(
+                            "http_request",
+                            request_id = ?request_id,
+                        ),
+                        None => {
+                            error!("could not extract request_id");
+                            info_span!("http_request")
+                        }
                     }
-                }
-            },
+                },
             ),
             // TODO(msi): from config
             TimeoutLayer::new(Duration::from_secs(10)),
-            PropagateRequestIdLayer::new(x_request_id)
+            PropagateRequestIdLayer::new(x_request_id),
         ))
+        .route_layer(middleware::from_fn(track_metrics))
         .route("/healthz", get(healthz))
         .with_state(app_state)
 }
blob - /dev/null
blob + 91a26cc83a75da6ba33057319bf927e8a0a64c2a (mode 644)
--- /dev/null
+++ web/template/src/metric.rs
@@ -0,0 +1,103 @@
+//
+// Copyright (c) 2025 murilo ijanc' <murilo@ijanc.org>
+//
+// Permission to use, copy, modify, and distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+//
+
+use std::{
+    future::ready,
+    time::{Duration, Instant},
+};
+
+use axum::{
+    Router,
+    extract::{MatchedPath, Request},
+    middleware::Next,
+    response::IntoResponse,
+    routing::get,
+};
+use metrics_exporter_prometheus::{
+    Matcher, PrometheusBuilder, PrometheusHandle,
+};
+
+use crate::helpers;
+
+pub(crate) async fn start_metrics_server() -> anyhow::Result<()> {
+    let app = metrics_app();
+
+    let listener = tokio::net::TcpListener::bind("127.0.0.1:3001").await?;
+    tracing::info!("metrics listening on {}", listener.local_addr()?);
+    axum::serve(listener, app)
+        .with_graceful_shutdown(helpers::shutdown_signal())
+        .await?;
+
+    Ok(())
+}
+
+fn metrics_app() -> Router {
+    let recorder_handle = setup_metrics_recorder();
+    Router::new()
+        .route("/metrics", get(move || ready(recorder_handle.render())))
+}
+
+fn setup_metrics_recorder() -> PrometheusHandle {
+    const EXPONENTIAL_SECONDS: &[f64] =
+        &[0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0];
+
+    let recorder_handle = PrometheusBuilder::new()
+        .set_buckets_for_metric(
+            Matcher::Full("http_requests_duration_seconds".to_string()),
+            EXPONENTIAL_SECONDS,
+        )
+        .unwrap()
+        .install_recorder()
+        .unwrap();
+
+    let upkeep_handle = recorder_handle.clone();
+    tokio::spawn(async move {
+        loop {
+            tokio::time::sleep(Duration::from_secs(5)).await;
+            upkeep_handle.run_upkeep();
+        }
+    });
+
+    recorder_handle
+}
+
+pub(crate) async fn track_metrics(
+    req: Request,
+    next: Next,
+) -> impl IntoResponse {
+    let start = Instant::now();
+    let path =
+        if let Some(matched_path) = req.extensions().get::<MatchedPath>() {
+            matched_path.as_str().to_owned()
+        } else {
+            req.uri().path().to_owned()
+        };
+    let method = req.method().clone();
+
+    let response = next.run(req).await;
+
+    let latency = start.elapsed().as_secs_f64();
+    let status = response.status().as_u16().to_string();
+
+    let labels =
+        [("method", method.to_string()), ("path", path), ("status", status)];
+
+    metrics::counter!("http_requests_total", &labels).increment(1);
+    metrics::histogram!("http_requests_duration_seconds", &labels)
+        .record(latency);
+
+    response
+}