1use std::fmt::Write as _;
14use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
19use tokio::net::TcpListener;
20use tokio::sync::watch;
21use tracing::debug;
22
23const LATENCY_BUCKETS: [f64; 9] = [0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0];
26
27const MAX_REQUEST_BYTES: usize = 8 * 1024;
30
31const READ_TIMEOUT: Duration = Duration::from_secs(5);
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum QueryOutcome {
37 Ok,
38 Error,
39 Timeout,
40 MemoryLimit,
41}
42
43pub struct Metrics {
46 start: Instant,
47 version: &'static str,
48
49 connections_active: AtomicU64,
50 connections_accepted_total: AtomicU64,
51 tls_handshake_failures_total: AtomicU64,
52
53 queries_ok_total: AtomicU64,
54 queries_error_total: AtomicU64,
55 queries_in_flight: AtomicU64,
56 query_timeouts_total: AtomicU64,
57 query_memory_limit_exceeded_total: AtomicU64,
58
59 auth_failures_total: AtomicU64,
60
61 latency_buckets: [AtomicU64; LATENCY_BUCKETS.len() + 1],
64 latency_sum_nanos: AtomicU64,
65}
66
67impl Default for Metrics {
68 fn default() -> Self {
69 Self::new()
70 }
71}
72
73impl Metrics {
74 pub fn new() -> Self {
75 Self {
76 start: Instant::now(),
77 version: env!("CARGO_PKG_VERSION"),
78 connections_active: AtomicU64::new(0),
79 connections_accepted_total: AtomicU64::new(0),
80 tls_handshake_failures_total: AtomicU64::new(0),
81 queries_ok_total: AtomicU64::new(0),
82 queries_error_total: AtomicU64::new(0),
83 queries_in_flight: AtomicU64::new(0),
84 query_timeouts_total: AtomicU64::new(0),
85 query_memory_limit_exceeded_total: AtomicU64::new(0),
86 auth_failures_total: AtomicU64::new(0),
87 latency_buckets: std::array::from_fn(|_| AtomicU64::new(0)),
88 latency_sum_nanos: AtomicU64::new(0),
89 }
90 }
91
92 pub fn record_query(&self, elapsed: Duration, outcome: QueryOutcome) {
94 match outcome {
95 QueryOutcome::Ok => {
96 self.queries_ok_total.fetch_add(1, Relaxed);
97 }
98 QueryOutcome::Error => {
99 self.queries_error_total.fetch_add(1, Relaxed);
100 }
101 QueryOutcome::Timeout => {
102 self.queries_error_total.fetch_add(1, Relaxed);
103 self.query_timeouts_total.fetch_add(1, Relaxed);
104 }
105 QueryOutcome::MemoryLimit => {
106 self.queries_error_total.fetch_add(1, Relaxed);
107 self.query_memory_limit_exceeded_total.fetch_add(1, Relaxed);
108 }
109 }
110 let secs = elapsed.as_secs_f64();
111 let idx = LATENCY_BUCKETS
112 .iter()
113 .position(|&b| secs <= b)
114 .unwrap_or(LATENCY_BUCKETS.len());
115 self.latency_buckets[idx].fetch_add(1, Relaxed);
116 self.latency_sum_nanos
117 .fetch_add(elapsed.as_nanos() as u64, Relaxed);
118 }
119
120 pub fn inc_connection_accepted(&self) {
121 self.connections_accepted_total.fetch_add(1, Relaxed);
122 }
123
124 pub fn inc_auth_failure(&self) {
125 self.auth_failures_total.fetch_add(1, Relaxed);
126 }
127
128 pub fn inc_tls_failure(&self) {
129 self.tls_handshake_failures_total.fetch_add(1, Relaxed);
130 }
131
132 pub fn active_guard(self: &Arc<Self>) -> ActiveGuard {
135 self.connections_active.fetch_add(1, Relaxed);
136 ActiveGuard(self.clone())
137 }
138
139 pub fn in_flight_guard(self: &Arc<Self>) -> InFlightGuard {
141 self.queries_in_flight.fetch_add(1, Relaxed);
142 InFlightGuard(self.clone())
143 }
144
145 pub fn render(&self) -> String {
147 let mut out = String::with_capacity(2048);
148
149 out.push_str("# HELP powdb_build_info Build information.\n");
151 out.push_str("# TYPE powdb_build_info gauge\n");
152 let _ = writeln!(
153 out,
154 "powdb_build_info{{version=\"{}\"}} 1",
155 escape_label(self.version)
156 );
157
158 gauge_f64(
159 &mut out,
160 "powdb_uptime_seconds",
161 "Seconds since the server started.",
162 self.start.elapsed().as_secs_f64(),
163 );
164 gauge_u64(
165 &mut out,
166 "powdb_connections_active",
167 "Currently open client connections.",
168 self.connections_active.load(Relaxed),
169 );
170 counter(
171 &mut out,
172 "powdb_connections_accepted_total",
173 "Total client connections accepted.",
174 self.connections_accepted_total.load(Relaxed),
175 );
176 counter(
177 &mut out,
178 "powdb_tls_handshake_failures_total",
179 "Total TLS handshakes that failed.",
180 self.tls_handshake_failures_total.load(Relaxed),
181 );
182
183 out.push_str("# HELP powdb_queries_total Total queries executed, by result.\n");
185 out.push_str("# TYPE powdb_queries_total counter\n");
186 let _ = writeln!(
187 out,
188 "powdb_queries_total{{result=\"ok\"}} {}",
189 self.queries_ok_total.load(Relaxed)
190 );
191 let _ = writeln!(
192 out,
193 "powdb_queries_total{{result=\"error\"}} {}",
194 self.queries_error_total.load(Relaxed)
195 );
196
197 gauge_u64(
198 &mut out,
199 "powdb_queries_in_flight",
200 "Queries currently executing (saturation behind the engine lock).",
201 self.queries_in_flight.load(Relaxed),
202 );
203 counter(
204 &mut out,
205 "powdb_query_timeouts_total",
206 "Total queries whose execution exceeded the configured query timeout threshold.",
207 self.query_timeouts_total.load(Relaxed),
208 );
209 counter(
210 &mut out,
211 "powdb_query_memory_limit_exceeded_total",
212 "Total queries rejected by the per-query memory budget.",
213 self.query_memory_limit_exceeded_total.load(Relaxed),
214 );
215 counter(
216 &mut out,
217 "powdb_auth_failures_total",
218 "Total authentication failures.",
219 self.auth_failures_total.load(Relaxed),
220 );
221
222 out.push_str("# HELP powdb_query_duration_seconds Query execution time in seconds.\n");
225 out.push_str("# TYPE powdb_query_duration_seconds histogram\n");
226 let mut cumulative = 0u64;
227 for (i, &bound) in LATENCY_BUCKETS.iter().enumerate() {
228 cumulative += self.latency_buckets[i].load(Relaxed);
229 let _ = writeln!(
230 out,
231 "powdb_query_duration_seconds_bucket{{le=\"{bound}\"}} {cumulative}"
232 );
233 }
234 cumulative += self.latency_buckets[LATENCY_BUCKETS.len()].load(Relaxed);
235 let _ = writeln!(
236 out,
237 "powdb_query_duration_seconds_bucket{{le=\"+Inf\"}} {cumulative}"
238 );
239 let sum_secs = self.latency_sum_nanos.load(Relaxed) as f64 / 1e9;
240 let _ = writeln!(out, "powdb_query_duration_seconds_sum {sum_secs}");
241 let _ = writeln!(out, "powdb_query_duration_seconds_count {cumulative}");
242
243 out
244 }
245}
246
247pub struct ActiveGuard(Arc<Metrics>);
249impl Drop for ActiveGuard {
250 fn drop(&mut self) {
251 self.0.connections_active.fetch_sub(1, Relaxed);
252 }
253}
254
255pub struct InFlightGuard(Arc<Metrics>);
257impl Drop for InFlightGuard {
258 fn drop(&mut self) {
259 self.0.queries_in_flight.fetch_sub(1, Relaxed);
260 }
261}
262
263fn counter(out: &mut String, name: &str, help: &str, value: u64) {
264 let _ = writeln!(out, "# HELP {name} {help}");
265 let _ = writeln!(out, "# TYPE {name} counter");
266 let _ = writeln!(out, "{name} {value}");
267}
268
269fn gauge_u64(out: &mut String, name: &str, help: &str, value: u64) {
270 let _ = writeln!(out, "# HELP {name} {help}");
271 let _ = writeln!(out, "# TYPE {name} gauge");
272 let _ = writeln!(out, "{name} {value}");
273}
274
275fn gauge_f64(out: &mut String, name: &str, help: &str, value: f64) {
276 let _ = writeln!(out, "# HELP {name} {help}");
277 let _ = writeln!(out, "# TYPE {name} gauge");
278 let _ = writeln!(out, "{name} {value}");
279}
280
281fn escape_label(s: &str) -> String {
283 s.replace('\\', "\\\\")
284 .replace('"', "\\\"")
285 .replace('\n', "\\n")
286}
287
288pub async fn serve_metrics(
292 listener: TcpListener,
293 metrics: Arc<Metrics>,
294 mut shutdown_rx: watch::Receiver<bool>,
295) {
296 loop {
297 tokio::select! {
298 accepted = listener.accept() => {
299 match accepted {
300 Ok((stream, _peer)) => {
301 let m = metrics.clone();
302 tokio::spawn(async move { handle_scrape(stream, m).await; });
303 }
304 Err(e) => debug!(error = %e, "metrics accept error"),
305 }
306 }
307 _ = shutdown_rx.changed() => {
308 if *shutdown_rx.borrow() {
309 break;
310 }
311 }
312 }
313 }
314}
315
316async fn handle_scrape<S>(mut stream: S, metrics: Arc<Metrics>)
319where
320 S: AsyncRead + AsyncWrite + Unpin,
321{
322 let mut buf: Vec<u8> = Vec::with_capacity(256);
323 let mut chunk = [0u8; 1024];
324
325 let request_line = loop {
326 if let Some(end) = find_line_end(&buf) {
327 break String::from_utf8_lossy(&buf[..end]).into_owned();
328 }
329 if buf.len() >= MAX_REQUEST_BYTES {
330 let _ = respond(&mut stream, 400, "text/plain", "request too large\n").await;
331 return;
332 }
333 match tokio::time::timeout(READ_TIMEOUT, stream.read(&mut chunk)).await {
334 Ok(Ok(0)) => return, Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
336 Ok(Err(e)) => {
337 debug!(error = %e, "metrics read error");
338 return;
339 }
340 Err(_) => {
341 debug!("metrics read timeout (slow client)");
342 return;
343 }
344 }
345 };
346
347 let mut parts = request_line.split_whitespace();
348 let method = parts.next().unwrap_or("");
349 let path = parts.next().unwrap_or("");
350
351 if method == "GET" && path == "/metrics" {
352 let body = metrics.render();
353 let _ = respond(
354 &mut stream,
355 200,
356 "text/plain; version=0.0.4; charset=utf-8",
357 &body,
358 )
359 .await;
360 } else {
361 let _ = respond(&mut stream, 404, "text/plain", "not found\n").await;
362 }
363}
364
365fn find_line_end(buf: &[u8]) -> Option<usize> {
368 buf.iter().position(|&b| b == b'\n').map(|nl| {
369 if nl > 0 && buf[nl - 1] == b'\r' {
370 nl - 1
371 } else {
372 nl
373 }
374 })
375}
376
377async fn respond<S>(
378 stream: &mut S,
379 status: u16,
380 content_type: &str,
381 body: &str,
382) -> std::io::Result<()>
383where
384 S: AsyncWrite + Unpin,
385{
386 let reason = match status {
387 200 => "OK",
388 400 => "Bad Request",
389 404 => "Not Found",
390 _ => "OK",
391 };
392 let response = format!(
393 "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
394 body.len()
395 );
396 stream.write_all(response.as_bytes()).await?;
397 stream.flush().await
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn render_has_required_families_and_help_type() {
406 let m = Metrics::new();
407 let out = m.render();
408 for name in [
409 "powdb_build_info",
410 "powdb_uptime_seconds",
411 "powdb_connections_active",
412 "powdb_connections_accepted_total",
413 "powdb_tls_handshake_failures_total",
414 "powdb_queries_total",
415 "powdb_queries_in_flight",
416 "powdb_query_timeouts_total",
417 "powdb_query_memory_limit_exceeded_total",
418 "powdb_auth_failures_total",
419 "powdb_query_duration_seconds",
420 ] {
421 assert!(
422 out.contains(&format!("# HELP {name}")),
423 "missing HELP {name}"
424 );
425 assert!(
426 out.contains(&format!("# TYPE {name}")),
427 "missing TYPE {name}"
428 );
429 }
430 assert!(out.contains("powdb_queries_total{result=\"ok\"} 0"));
432 assert!(out.contains("powdb_queries_total{result=\"error\"} 0"));
433 assert!(out.contains("powdb_build_info{version=\""));
434 }
435
436 #[test]
437 fn histogram_buckets_are_cumulative_and_inf_equals_count() {
438 let m = Metrics::new();
439 m.record_query(Duration::from_micros(300), QueryOutcome::Ok); m.record_query(Duration::from_millis(3), QueryOutcome::Ok); m.record_query(Duration::from_secs(10), QueryOutcome::Error); let out = m.render();
443
444 assert!(out.contains("powdb_query_duration_seconds_bucket{le=\"0.0005\"} 1"));
446 assert!(out.contains("powdb_query_duration_seconds_bucket{le=\"0.005\"} 2"));
447 assert!(out.contains("powdb_query_duration_seconds_bucket{le=\"+Inf\"} 3"));
448 assert!(out.contains("powdb_query_duration_seconds_count 3"));
449 assert!(out.contains("powdb_queries_total{result=\"ok\"} 2"));
451 assert!(out.contains("powdb_queries_total{result=\"error\"} 1"));
452 }
453
454 #[test]
455 fn outcome_counters_route_correctly() {
456 let m = Metrics::new();
457 m.record_query(Duration::from_millis(1), QueryOutcome::Timeout);
458 m.record_query(Duration::from_millis(1), QueryOutcome::MemoryLimit);
459 let out = m.render();
460 assert!(out.contains("powdb_queries_total{result=\"error\"} 2"));
462 assert!(out.contains("powdb_query_timeouts_total 1"));
463 assert!(out.contains("powdb_query_memory_limit_exceeded_total 1"));
464 }
465
466 #[test]
467 fn guards_increment_and_decrement() {
468 let m = Arc::new(Metrics::new());
469 {
470 let _a = m.active_guard();
471 let _b = m.active_guard();
472 let _f = m.in_flight_guard();
473 assert!(m.render().contains("powdb_connections_active 2"));
474 assert!(m.render().contains("powdb_queries_in_flight 1"));
475 }
476 assert!(m.render().contains("powdb_connections_active 0"));
477 assert!(m.render().contains("powdb_queries_in_flight 0"));
478 }
479
480 #[test]
481 fn escape_label_escapes_special_chars() {
482 assert_eq!(escape_label(r#"a\b"c"#), r#"a\\b\"c"#);
483 assert_eq!(escape_label("a\nb"), "a\\nb");
484 assert_eq!(escape_label("0.5.1"), "0.5.1");
485 }
486
487 #[test]
488 fn find_line_end_handles_crlf_lf_and_none() {
489 assert_eq!(find_line_end(b"GET / HTTP/1.1\r\n"), Some(14));
490 assert_eq!(find_line_end(b"GET / HTTP/1.1\n"), Some(14));
491 assert_eq!(find_line_end(b"GET / HTTP/1.1"), None);
492 assert_eq!(find_line_end(b"\n"), Some(0));
493 }
494
495 #[test]
496 fn concurrent_record_query_is_consistent_at_quiescence() {
497 use std::thread;
498 let m = Arc::new(Metrics::new());
499 let mut handles = Vec::new();
500 for _ in 0..8 {
501 let m = m.clone();
502 handles.push(thread::spawn(move || {
503 for _ in 0..1000 {
504 m.record_query(Duration::from_millis(2), QueryOutcome::Ok);
505 }
506 }));
507 }
508 for _ in 0..50 {
510 let _ = m.render();
511 }
512 for h in handles {
513 h.join().unwrap();
514 }
515 let out = m.render();
516 assert!(out.contains("powdb_queries_total{result=\"ok\"} 8000"));
517 assert!(out.contains("powdb_query_duration_seconds_count 8000"));
518 assert!(out.contains("powdb_query_duration_seconds_bucket{le=\"+Inf\"} 8000"));
519 }
520
521 #[tokio::test]
522 async fn handle_scrape_serves_metrics_and_404s_other_paths() {
523 use tokio::io::{AsyncReadExt, AsyncWriteExt};
524
525 let (mut client, server) = tokio::io::duplex(8192);
527 let m = Arc::new(Metrics::new());
528 let task = tokio::spawn(handle_scrape(server, m));
529 client
530 .write_all(b"GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n")
531 .await
532 .unwrap();
533 let mut resp = String::new();
534 client.read_to_string(&mut resp).await.unwrap();
535 task.await.unwrap();
536 assert!(resp.starts_with("HTTP/1.1 200 OK"), "resp: {resp}");
537 assert!(resp.contains("powdb_build_info"));
538
539 let (mut client, server) = tokio::io::duplex(8192);
541 let m = Arc::new(Metrics::new());
542 let task = tokio::spawn(handle_scrape(server, m));
543 client
544 .write_all(b"GET /nope HTTP/1.1\r\n\r\n")
545 .await
546 .unwrap();
547 let mut resp = String::new();
548 client.read_to_string(&mut resp).await.unwrap();
549 task.await.unwrap();
550 assert!(resp.starts_with("HTTP/1.1 404 Not Found"), "resp: {resp}");
551 }
552
553 #[tokio::test]
554 async fn handle_scrape_rejects_garbage_without_panicking() {
555 use tokio::io::{AsyncReadExt, AsyncWriteExt};
556 let (mut client, server) = tokio::io::duplex(8192);
557 let m = Arc::new(Metrics::new());
558 let task = tokio::spawn(handle_scrape(server, m));
559 client
560 .write_all(b"\x00\x01\x02 garbage\r\n\r\n")
561 .await
562 .unwrap();
563 let mut resp = String::new();
564 client.read_to_string(&mut resp).await.unwrap();
565 task.await.unwrap();
566 assert!(resp.starts_with("HTTP/1.1 404"), "resp: {resp}");
567 }
568}