diff options
| author | msi | 2025-11-13 16:42:07 -0300 |
|---|---|---|
| committer | msi | 2025-11-13 16:42:07 -0300 |
| commit | 87e1be84a219cdfabd7d13aa98d7fd66b0d05704 (patch) | |
| tree | 985d3c9e9c7c0bc2791c746e5e99e6be4692d99c /web/template | |
| parent | 901713323c305809dbcf20c2d33505e2c65ea5eb (diff) | |
| download | templates-87e1be84a219cdfabd7d13aa98d7fd66b0d05704.tar.gz | |
Add prometheus metrics
Diffstat (limited to 'web/template')
| -rw-r--r-- | web/template/Cargo.toml | 3 | ||||
| -rw-r--r-- | web/template/src/main.rs | 8 | ||||
| -rw-r--r-- | web/template/src/metric.rs | 103 | ||||
| -rw-r--r-- | web/template/src/router.rs | 33 |
4 files changed, 131 insertions, 16 deletions
diff --git a/web/template/Cargo.toml b/web/template/Cargo.toml index c43f0bf..4d421e1 100644 --- a/web/template/Cargo.toml +++ b/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"] } + diff --git a/web/template/src/main.rs b/web/template/src/main.rs index 20a1974..ba99019 100644 --- a/web/template/src/main.rs +++ b/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(()) } diff --git a/web/template/src/metric.rs b/web/template/src/metric.rs new file mode 100644 index 0000000..91a26cc --- /dev/null +++ b/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 +} diff --git a/web/template/src/router.rs b/web/template/src/router.rs index d3bc57c..1e00216 100644 --- a/web/template/src/router.rs +++ b/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>) -> Router { .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); - - 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") + |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") + } } - } - }, + }, ), // 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) } |