commit - 901713323c305809dbcf20c2d33505e2c65ea5eb
commit + 87e1be84a219cdfabd7d13aa98d7fd66b0d05704
blob - c43f0bf66015a2a1b7313fd4bb9e8584761a7fe0
blob + 4d421e1712dc62ac0ea7b179ab8e26577ac21756
--- web/template/Cargo.toml
+++ web/template/Cargo.toml
[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
use tracing::info;
mod helpers;
+mod metric;
mod router;
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"))?;
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
Router,
extract::State,
http::{HeaderName, Request, StatusCode},
+ middleware,
response::{Html, IntoResponse},
routing::get,
};
};
use tracing::{error, info_span};
+use crate::metric::track_metrics;
use crate::state::AppState;
const REQUEST_ID_HEADER: &str = "x-request-id";
.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
+//
+// 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
+}