Skip to main content

perfgate_server/
metrics.rs

1//! Prometheus metrics middleware and endpoint.
2//!
3//! This module provides:
4//! - A `/metrics` endpoint returning Prometheus text exposition format
5//! - A middleware layer that records request duration and status code
6//! - Custom counters for storage operations
7
8use std::time::Instant;
9
10use axum::{
11    body::Body,
12    extract::Request,
13    http::StatusCode,
14    middleware::Next,
15    response::{IntoResponse, Response},
16};
17use metrics::{counter, gauge, histogram};
18use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
19
20/// Metric name constants.
21pub mod names {
22    /// Histogram: perfgate server request duration in seconds, labeled by method, path, status.
23    pub const SERVER_REQUEST_DURATION_SECONDS: &str = "perfgate_server_request_duration_seconds";
24    /// Counter: total perfgate server HTTP requests, labeled by method, path, status.
25    pub const SERVER_REQUESTS_TOTAL: &str = "perfgate_server_requests_total";
26    /// Gauge: number of in-flight perfgate server requests.
27    pub const SERVER_REQUESTS_IN_FLIGHT: &str = "perfgate_server_requests_in_flight";
28    /// Histogram: request duration in seconds, labeled by method, path, status.
29    pub const HTTP_REQUEST_DURATION_SECONDS: &str = "http_request_duration_seconds";
30    /// Counter: total HTTP requests, labeled by method, path, status.
31    pub const HTTP_REQUESTS_TOTAL: &str = "http_requests_total";
32    /// Gauge: number of in-flight requests.
33    pub const HTTP_REQUESTS_IN_FLIGHT: &str = "http_requests_in_flight";
34    /// Counter: total baseline uploads.
35    pub const BASELINE_UPLOADS_TOTAL: &str = "perfgate_baseline_uploads_total";
36    /// Counter: total baseline downloads.
37    pub const BASELINE_DOWNLOADS_TOTAL: &str = "perfgate_baseline_downloads_total";
38    /// Counter: total storage operations, labeled by operation.
39    pub const STORAGE_OPERATIONS_TOTAL: &str = "perfgate_storage_operations_total";
40    /// Gauge: most recent known number of baselines, labeled by project.
41    pub const BASELINES_TOTAL: &str = "perfgate_baselines_total";
42    /// Counter: total submitted verdicts, labeled by project and status.
43    pub const VERDICTS_TOTAL: &str = "perfgate_verdicts_total";
44    /// Counter: total failed baseline uploads, labeled by project and reason.
45    pub const UPLOAD_FAILURES_TOTAL: &str = "perfgate_upload_failures_total";
46    /// Counter: total authentication or authorization failures, labeled by reason.
47    pub const AUTH_FAILURES_TOTAL: &str = "perfgate_auth_failures_total";
48    /// Counter: total storage errors, labeled by operation.
49    pub const STORAGE_ERRORS_TOTAL: &str = "perfgate_storage_errors_total";
50}
51
52/// Installs the Prometheus recorder and returns a handle for rendering metrics.
53///
54/// Must be called once at server startup before any metrics are recorded.
55pub fn setup_metrics_recorder() -> PrometheusHandle {
56    PrometheusBuilder::new()
57        .install_recorder()
58        .expect("failed to install Prometheus recorder")
59}
60
61/// Axum handler that renders collected metrics in Prometheus text exposition format.
62pub async fn metrics_handler(
63    axum::extract::State(handle): axum::extract::State<PrometheusHandle>,
64) -> impl IntoResponse {
65    let body = handle.render();
66    Response::builder()
67        .status(StatusCode::OK)
68        .header(
69            axum::http::header::CONTENT_TYPE,
70            "text/plain; version=0.0.4; charset=utf-8",
71        )
72        .body(Body::from(body))
73        .unwrap()
74}
75
76/// Normalizes a URI path for use as a metrics label.
77///
78/// Replaces dynamic path segments with placeholders to avoid high-cardinality labels.
79fn normalize_path(path: &str) -> String {
80    let segments: Vec<&str> = path.split('/').collect();
81    let mut normalized = Vec::with_capacity(segments.len());
82
83    let mut i = 0;
84    while i < segments.len() {
85        let seg = segments[i];
86        match seg {
87            "projects" => {
88                normalized.push("projects");
89                // Next segment is the project name -> replace with placeholder
90                if i + 1 < segments.len() {
91                    normalized.push(":project");
92                    i += 1;
93                }
94            }
95            "baselines" => {
96                normalized.push("baselines");
97                // Next segment is benchmark name -> replace with placeholder
98                if i + 1 < segments.len() && !segments[i + 1].is_empty() {
99                    let next = segments[i + 1];
100                    // Check if it's a known sub-path or a dynamic segment
101                    if next != "latest" && next != "versions" && next != "promote" {
102                        normalized.push(":benchmark");
103                        i += 1;
104                    }
105                }
106            }
107            "versions" => {
108                normalized.push("versions");
109                // Next segment is the version -> replace with placeholder
110                if i + 1 < segments.len() {
111                    normalized.push(":version");
112                    i += 1;
113                }
114            }
115            "verdicts" => {
116                normalized.push("verdicts");
117            }
118            _ => {
119                normalized.push(seg);
120            }
121        }
122        i += 1;
123    }
124
125    normalized.join("/")
126}
127
128/// Middleware that records HTTP request metrics.
129///
130/// Records:
131/// - `perfgate_server_request_duration_seconds` histogram
132/// - `perfgate_server_requests_total` counter
133/// - `perfgate_server_requests_in_flight` gauge
134/// - compatibility `http_*` metrics with the same labels
135pub async fn metrics_middleware(request: Request, next: Next) -> Response {
136    let method = request.method().to_string();
137    let path = normalize_path(request.uri().path());
138
139    gauge!(names::SERVER_REQUESTS_IN_FLIGHT).increment(1);
140    gauge!(names::HTTP_REQUESTS_IN_FLIGHT).increment(1);
141    let start = Instant::now();
142
143    let response = next.run(request).await;
144
145    let duration = start.elapsed().as_secs_f64();
146    let status = response.status().as_u16().to_string();
147
148    let labels = [
149        ("method", method.clone()),
150        ("path", path.clone()),
151        ("status", status.clone()),
152    ];
153
154    histogram!(names::SERVER_REQUEST_DURATION_SECONDS, &labels).record(duration);
155    counter!(names::SERVER_REQUESTS_TOTAL, &labels).increment(1);
156    histogram!(names::HTTP_REQUEST_DURATION_SECONDS, &labels).record(duration);
157    counter!(names::HTTP_REQUESTS_TOTAL, &labels).increment(1);
158    gauge!(names::SERVER_REQUESTS_IN_FLIGHT).decrement(1);
159    gauge!(names::HTTP_REQUESTS_IN_FLIGHT).decrement(1);
160
161    response
162}
163
164/// Records a successful baseline upload.
165pub fn record_baseline_upload(project: &str) {
166    counter!(names::BASELINE_UPLOADS_TOTAL, "project" => project.to_string()).increment(1);
167    counter!(names::STORAGE_OPERATIONS_TOTAL, "operation" => "upload").increment(1);
168}
169
170/// Records a successful baseline download (get/get_latest).
171pub fn record_baseline_download(project: &str) {
172    counter!(names::BASELINE_DOWNLOADS_TOTAL, "project" => project.to_string()).increment(1);
173    counter!(names::STORAGE_OPERATIONS_TOTAL, "operation" => "download").increment(1);
174}
175
176/// Records a storage list operation.
177pub fn record_storage_list() {
178    counter!(names::STORAGE_OPERATIONS_TOTAL, "operation" => "list").increment(1);
179}
180
181/// Records a storage delete operation.
182pub fn record_storage_delete() {
183    counter!(names::STORAGE_OPERATIONS_TOTAL, "operation" => "delete").increment(1);
184}
185
186/// Records a storage promote operation.
187pub fn record_storage_promote() {
188    counter!(names::STORAGE_OPERATIONS_TOTAL, "operation" => "promote").increment(1);
189}
190
191/// Records the latest known baseline count for a project.
192pub fn record_baselines_total(project: &str, total: u64) {
193    gauge!(names::BASELINES_TOTAL, "project" => project.to_string()).set(total as f64);
194}
195
196/// Records a submitted verdict.
197pub fn record_verdict_submit(project: &str, status: &str) {
198    counter!(
199        names::VERDICTS_TOTAL,
200        "project" => project.to_string(),
201        "status" => status.to_string()
202    )
203    .increment(1);
204}
205
206/// Records a failed baseline upload.
207pub fn record_upload_failure(project: &str, reason: &'static str) {
208    counter!(
209        names::UPLOAD_FAILURES_TOTAL,
210        "project" => project.to_string(),
211        "reason" => reason
212    )
213    .increment(1);
214}
215
216/// Records an authentication or authorization failure.
217pub fn record_auth_failure(reason: &'static str) {
218    counter!(names::AUTH_FAILURES_TOTAL, "reason" => reason).increment(1);
219}
220
221/// Records a storage-layer error.
222pub fn record_storage_error(operation: &'static str) {
223    counter!(names::STORAGE_ERRORS_TOTAL, "operation" => operation).increment(1);
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn test_normalize_path_health() {
232        assert_eq!(normalize_path("/health"), "/health");
233    }
234
235    #[test]
236    fn test_normalize_path_baselines_upload() {
237        assert_eq!(
238            normalize_path("/api/v1/projects/my-proj/baselines"),
239            "/api/v1/projects/:project/baselines"
240        );
241    }
242
243    #[test]
244    fn test_normalize_path_latest() {
245        assert_eq!(
246            normalize_path("/api/v1/projects/my-proj/baselines/my-bench/latest"),
247            "/api/v1/projects/:project/baselines/:benchmark/latest"
248        );
249    }
250
251    #[test]
252    fn test_normalize_path_version() {
253        assert_eq!(
254            normalize_path("/api/v1/projects/my-proj/baselines/my-bench/versions/v1"),
255            "/api/v1/projects/:project/baselines/:benchmark/versions/:version"
256        );
257    }
258
259    #[test]
260    fn test_normalize_path_promote() {
261        assert_eq!(
262            normalize_path("/api/v1/projects/my-proj/baselines/my-bench/promote"),
263            "/api/v1/projects/:project/baselines/:benchmark/promote"
264        );
265    }
266
267    #[test]
268    fn test_normalize_path_verdicts() {
269        assert_eq!(
270            normalize_path("/api/v1/projects/my-proj/verdicts"),
271            "/api/v1/projects/:project/verdicts"
272        );
273    }
274
275    #[test]
276    fn test_normalize_path_metrics() {
277        assert_eq!(normalize_path("/metrics"), "/metrics");
278    }
279
280    #[test]
281    fn test_normalize_path_root() {
282        assert_eq!(normalize_path("/"), "/");
283    }
284
285    #[test]
286    fn operational_metrics_render_perfgate_prefixed_names() {
287        let recorder = PrometheusBuilder::new().build_recorder();
288        let handle = recorder.handle();
289
290        metrics::with_local_recorder(&recorder, || {
291            record_baseline_upload("proj");
292            record_baseline_download("proj");
293            record_baselines_total("proj", 2);
294            record_verdict_submit("proj", "pass");
295            record_upload_failure("proj", "storage");
296            record_auth_failure("missing_credentials");
297            record_storage_error("upload_baseline");
298        });
299
300        let rendered = handle.render();
301        assert!(rendered.contains(names::BASELINE_UPLOADS_TOTAL));
302        assert!(rendered.contains(names::BASELINE_DOWNLOADS_TOTAL));
303        assert!(rendered.contains(names::BASELINES_TOTAL));
304        assert!(rendered.contains(names::VERDICTS_TOTAL));
305        assert!(rendered.contains(names::UPLOAD_FAILURES_TOTAL));
306        assert!(rendered.contains(names::AUTH_FAILURES_TOTAL));
307        assert!(rendered.contains(names::STORAGE_ERRORS_TOTAL));
308    }
309}