Skip to main content

powdb_server/
metrics.rs

1//! Lock-free Prometheus metrics for `powdb-server`.
2//!
3//! All counters/gauges are plain atomics behind an `Arc<Metrics>` shared into
4//! every connection handler, so updating them never blocks and a `/metrics`
5//! scrape never touches the `RwLock<Engine>`. Exposed over a small, separate
6//! HTTP listener (own port, opt-in) so the binary wire protocol is untouched.
7//!
8//! Histogram consistency: `render()` derives `_count` and the `le="+Inf"`
9//! bucket from the *same* in-render sum of the bucket atomics, so
10//! `+Inf == _count` always holds in a single exposition regardless of
11//! concurrent observers. At quiescence every counter is exact.
12
13use 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
23/// Upper bounds (seconds) for the query-latency histogram, ascending. The
24/// implicit final bucket is `le="+Inf"`.
25const LATENCY_BUCKETS: [f64; 9] = [0.0005, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0];
26
27/// Cap on bytes read from a metrics client before bailing. A scrape request
28/// line + headers is tiny; anything larger is junk or hostile.
29const MAX_REQUEST_BYTES: usize = 8 * 1024;
30
31/// A scrape must be snappy — do NOT reuse the 300s connection idle timeout.
32const READ_TIMEOUT: Duration = Duration::from_secs(5);
33
34/// How a finished query is classified for metrics.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum QueryOutcome {
37    Ok,
38    Error,
39    Timeout,
40    MemoryLimit,
41}
42
43/// All server metrics. Cheap to update (uncontended atomic add), lock-free to
44/// render.
45pub 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    // Query-latency histogram: one counter per finite bucket plus one overflow
62    // bucket for observations greater than the last bound.
63    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    /// Record a finished query: its result class and execution time.
93    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    /// RAII gauge: increments `connections_active` now, decrements on drop —
133    /// correct across every early return or panic in the connection task.
134    pub fn active_guard(self: &Arc<Self>) -> ActiveGuard {
135        self.connections_active.fetch_add(1, Relaxed);
136        ActiveGuard(self.clone())
137    }
138
139    /// RAII gauge: increments `queries_in_flight` now, decrements on drop.
140    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    /// Render the full exposition in Prometheus text format (v0.0.4).
146    pub fn render(&self) -> String {
147        let mut out = String::with_capacity(2048);
148
149        // build_info — value is always 1; the version is a label.
150        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        // queries_total{result} — always emit both label values.
184        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        // Latency histogram. Derive count and +Inf from the same bucket reads
223        // so +Inf == _count in this exposition no matter what observers do.
224        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
247/// Decrements `connections_active` when dropped.
248pub 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
255/// Decrements `queries_in_flight` when dropped.
256pub 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
281/// Escape a Prometheus label value: backslash, double-quote, newline.
282fn escape_label(s: &str) -> String {
283    s.replace('\\', "\\\\")
284        .replace('"', "\\\"")
285        .replace('\n', "\\n")
286}
287
288/// Serve `GET /metrics` over `listener` until the shutdown signal flips. Each
289/// connection is handled on its own task so one slow client can't wedge the
290/// accept loop; the listener drains on SIGINT/SIGTERM via the watch channel.
291pub 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
316/// Handle one scrape connection: read just the request line (capped + timed
317/// out), answer `GET /metrics` with the exposition, everything else with 404.
318async 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, // EOF before a complete request line
335            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
365/// Index just past the request line (position of `\n`, with a trailing `\r`
366/// trimmed). Handles `\r\n` and bare `\n`.
367fn 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        // Both label values always present.
431        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); // <= 0.0005
440        m.record_query(Duration::from_millis(3), QueryOutcome::Ok); // <= 0.005
441        m.record_query(Duration::from_secs(10), QueryOutcome::Error); // overflow (> 5s)
442        let out = m.render();
443
444        // le="0.0005" has 1; le="0.005" is cumulative => 2; +Inf => 3 == count.
445        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        // queries_total split: 2 ok, 1 error.
450        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        // Both count as errors, plus their specific counters.
461        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        // Concurrent scrapes must never panic or produce +Inf < count.
509        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        // GET /metrics -> 200 + exposition.
526        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        // Unknown path -> 404.
540        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}