perfgate_server/
metrics.rs1use 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
20pub mod names {
22 pub const SERVER_REQUEST_DURATION_SECONDS: &str = "perfgate_server_request_duration_seconds";
24 pub const SERVER_REQUESTS_TOTAL: &str = "perfgate_server_requests_total";
26 pub const SERVER_REQUESTS_IN_FLIGHT: &str = "perfgate_server_requests_in_flight";
28 pub const HTTP_REQUEST_DURATION_SECONDS: &str = "http_request_duration_seconds";
30 pub const HTTP_REQUESTS_TOTAL: &str = "http_requests_total";
32 pub const HTTP_REQUESTS_IN_FLIGHT: &str = "http_requests_in_flight";
34 pub const BASELINE_UPLOADS_TOTAL: &str = "perfgate_baseline_uploads_total";
36 pub const BASELINE_DOWNLOADS_TOTAL: &str = "perfgate_baseline_downloads_total";
38 pub const STORAGE_OPERATIONS_TOTAL: &str = "perfgate_storage_operations_total";
40 pub const BASELINES_TOTAL: &str = "perfgate_baselines_total";
42 pub const VERDICTS_TOTAL: &str = "perfgate_verdicts_total";
44 pub const UPLOAD_FAILURES_TOTAL: &str = "perfgate_upload_failures_total";
46 pub const AUTH_FAILURES_TOTAL: &str = "perfgate_auth_failures_total";
48 pub const STORAGE_ERRORS_TOTAL: &str = "perfgate_storage_errors_total";
50}
51
52pub fn setup_metrics_recorder() -> PrometheusHandle {
56 PrometheusBuilder::new()
57 .install_recorder()
58 .expect("failed to install Prometheus recorder")
59}
60
61pub 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
76fn 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 if i + 1 < segments.len() {
91 normalized.push(":project");
92 i += 1;
93 }
94 }
95 "baselines" => {
96 normalized.push("baselines");
97 if i + 1 < segments.len() && !segments[i + 1].is_empty() {
99 let next = segments[i + 1];
100 if next != "latest" && next != "versions" && next != "promote" {
102 normalized.push(":benchmark");
103 i += 1;
104 }
105 }
106 }
107 "versions" => {
108 normalized.push("versions");
109 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
128pub 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
164pub 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
170pub 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
176pub fn record_storage_list() {
178 counter!(names::STORAGE_OPERATIONS_TOTAL, "operation" => "list").increment(1);
179}
180
181pub fn record_storage_delete() {
183 counter!(names::STORAGE_OPERATIONS_TOTAL, "operation" => "delete").increment(1);
184}
185
186pub fn record_storage_promote() {
188 counter!(names::STORAGE_OPERATIONS_TOTAL, "operation" => "promote").increment(1);
189}
190
191pub fn record_baselines_total(project: &str, total: u64) {
193 gauge!(names::BASELINES_TOTAL, "project" => project.to_string()).set(total as f64);
194}
195
196pub 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
206pub 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
216pub fn record_auth_failure(reason: &'static str) {
218 counter!(names::AUTH_FAILURES_TOTAL, "reason" => reason).increment(1);
219}
220
221pub 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}