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];
26const SYNC_OPERATION_COUNT: usize = 3;
27const SYNC_OUTCOME_COUNT: usize = 2;
28const SYNC_REPAIR_ACTION_COUNT: usize = 4;
29
30/// Cap on bytes read from a metrics client before bailing. A scrape request
31/// line + headers is tiny; anything larger is junk or hostile.
32const MAX_REQUEST_BYTES: usize = 8 * 1024;
33
34/// A scrape must be snappy — do NOT reuse the 300s connection idle timeout.
35const READ_TIMEOUT: Duration = Duration::from_secs(5);
36
37/// How a finished query is classified for metrics.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum QueryOutcome {
40    Ok,
41    Error,
42    Timeout,
43    MemoryLimit,
44}
45
46/// Private sync protocol operation names. Keep this enum small and
47/// low-cardinality: replica ids belong in authenticated responses, not labels.
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum SyncOperation {
50    Status,
51    Pull,
52    Ack,
53}
54
55impl SyncOperation {
56    const ALL: [Self; SYNC_OPERATION_COUNT] = [Self::Status, Self::Pull, Self::Ack];
57
58    const fn idx(self) -> usize {
59        match self {
60            Self::Status => 0,
61            Self::Pull => 1,
62            Self::Ack => 2,
63        }
64    }
65
66    const fn as_label(self) -> &'static str {
67        match self {
68            Self::Status => "status",
69            Self::Pull => "pull",
70            Self::Ack => "ack",
71        }
72    }
73}
74
75/// How a finished sync protocol frame is classified for metrics.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum SyncOutcome {
78    Ok,
79    Error,
80}
81
82impl SyncOutcome {
83    const ALL: [Self; SYNC_OUTCOME_COUNT] = [Self::Ok, Self::Error];
84
85    const fn idx(self) -> usize {
86        match self {
87            Self::Ok => 0,
88            Self::Error => 1,
89        }
90    }
91
92    const fn as_label(self) -> &'static str {
93        match self {
94            Self::Ok => "ok",
95            Self::Error => "error",
96        }
97    }
98}
99
100/// Low-cardinality repair actions returned inside sync status payloads.
101#[derive(Clone, Copy, Debug, PartialEq, Eq)]
102pub enum SyncRepairLabel {
103    None,
104    Pull,
105    AwaitArchive,
106    Rebootstrap,
107}
108
109impl SyncRepairLabel {
110    const ALL: [Self; SYNC_REPAIR_ACTION_COUNT] = [
111        Self::None,
112        Self::Pull,
113        Self::AwaitArchive,
114        Self::Rebootstrap,
115    ];
116
117    const fn idx(self) -> usize {
118        match self {
119            Self::None => 0,
120            Self::Pull => 1,
121            Self::AwaitArchive => 2,
122            Self::Rebootstrap => 3,
123        }
124    }
125
126    const fn as_label(self) -> &'static str {
127        match self {
128            Self::None => "none",
129            Self::Pull => "pull",
130            Self::AwaitArchive => "await_archive",
131            Self::Rebootstrap => "rebootstrap",
132        }
133    }
134}
135
136/// All server metrics. Cheap to update (uncontended atomic add), lock-free to
137/// render.
138pub struct Metrics {
139    start: Instant,
140    version: &'static str,
141
142    connections_active: AtomicU64,
143    connections_accepted_total: AtomicU64,
144    tls_handshake_failures_total: AtomicU64,
145
146    queries_ok_total: AtomicU64,
147    queries_error_total: AtomicU64,
148    queries_in_flight: AtomicU64,
149    query_timeouts_total: AtomicU64,
150    query_memory_limit_exceeded_total: AtomicU64,
151
152    auth_failures_total: AtomicU64,
153
154    // Query-latency histogram: one counter per finite bucket plus one overflow
155    // bucket for observations greater than the last bound.
156    latency_buckets: [AtomicU64; LATENCY_BUCKETS.len() + 1],
157    latency_sum_nanos: AtomicU64,
158
159    sync_operations_total: [[AtomicU64; SYNC_OUTCOME_COUNT]; SYNC_OPERATION_COUNT],
160    sync_repair_actions_total: [[AtomicU64; SYNC_REPAIR_ACTION_COUNT]; SYNC_OPERATION_COUNT],
161    sync_latency_buckets: [[AtomicU64; LATENCY_BUCKETS.len() + 1]; SYNC_OPERATION_COUNT],
162    sync_latency_sum_nanos: [AtomicU64; SYNC_OPERATION_COUNT],
163    sync_pull_units_total: AtomicU64,
164    sync_pull_bytes_total: AtomicU64,
165    sync_ack_advanced_total: AtomicU64,
166}
167
168impl Default for Metrics {
169    fn default() -> Self {
170        Self::new()
171    }
172}
173
174impl Metrics {
175    pub fn new() -> Self {
176        Self {
177            start: Instant::now(),
178            version: env!("CARGO_PKG_VERSION"),
179            connections_active: AtomicU64::new(0),
180            connections_accepted_total: AtomicU64::new(0),
181            tls_handshake_failures_total: AtomicU64::new(0),
182            queries_ok_total: AtomicU64::new(0),
183            queries_error_total: AtomicU64::new(0),
184            queries_in_flight: AtomicU64::new(0),
185            query_timeouts_total: AtomicU64::new(0),
186            query_memory_limit_exceeded_total: AtomicU64::new(0),
187            auth_failures_total: AtomicU64::new(0),
188            latency_buckets: std::array::from_fn(|_| AtomicU64::new(0)),
189            latency_sum_nanos: AtomicU64::new(0),
190            sync_operations_total: std::array::from_fn(|_| {
191                std::array::from_fn(|_| AtomicU64::new(0))
192            }),
193            sync_latency_buckets: std::array::from_fn(|_| {
194                std::array::from_fn(|_| AtomicU64::new(0))
195            }),
196            sync_latency_sum_nanos: std::array::from_fn(|_| AtomicU64::new(0)),
197            sync_pull_units_total: AtomicU64::new(0),
198            sync_pull_bytes_total: AtomicU64::new(0),
199            sync_repair_actions_total: std::array::from_fn(|_| {
200                std::array::from_fn(|_| AtomicU64::new(0))
201            }),
202            sync_ack_advanced_total: AtomicU64::new(0),
203        }
204    }
205
206    /// Record a finished query: its result class and execution time.
207    pub fn record_query(&self, elapsed: Duration, outcome: QueryOutcome) {
208        match outcome {
209            QueryOutcome::Ok => {
210                self.queries_ok_total.fetch_add(1, Relaxed);
211            }
212            QueryOutcome::Error => {
213                self.queries_error_total.fetch_add(1, Relaxed);
214            }
215            QueryOutcome::Timeout => {
216                self.queries_error_total.fetch_add(1, Relaxed);
217                self.query_timeouts_total.fetch_add(1, Relaxed);
218            }
219            QueryOutcome::MemoryLimit => {
220                self.queries_error_total.fetch_add(1, Relaxed);
221                self.query_memory_limit_exceeded_total.fetch_add(1, Relaxed);
222            }
223        }
224        let secs = elapsed.as_secs_f64();
225        let idx = LATENCY_BUCKETS
226            .iter()
227            .position(|&b| secs <= b)
228            .unwrap_or(LATENCY_BUCKETS.len());
229        self.latency_buckets[idx].fetch_add(1, Relaxed);
230        self.latency_sum_nanos
231            .fetch_add(elapsed.as_nanos() as u64, Relaxed);
232    }
233
234    /// Record a completed private sync protocol operation.
235    pub fn record_sync_operation(
236        &self,
237        operation: SyncOperation,
238        elapsed: Duration,
239        outcome: SyncOutcome,
240    ) {
241        let op_idx = operation.idx();
242        self.sync_operations_total[op_idx][outcome.idx()].fetch_add(1, Relaxed);
243
244        let secs = elapsed.as_secs_f64();
245        let bucket_idx = LATENCY_BUCKETS
246            .iter()
247            .position(|&b| secs <= b)
248            .unwrap_or(LATENCY_BUCKETS.len());
249        self.sync_latency_buckets[op_idx][bucket_idx].fetch_add(1, Relaxed);
250        self.sync_latency_sum_nanos[op_idx].fetch_add(elapsed.as_nanos() as u64, Relaxed);
251    }
252
253    pub fn record_sync_pull_payload(&self, units: u64, bytes: u64) {
254        self.sync_pull_units_total.fetch_add(units, Relaxed);
255        self.sync_pull_bytes_total.fetch_add(bytes, Relaxed);
256    }
257
258    pub fn record_sync_repair_action(&self, operation: SyncOperation, repair: SyncRepairLabel) {
259        self.sync_repair_actions_total[operation.idx()][repair.idx()].fetch_add(1, Relaxed);
260    }
261
262    pub fn inc_sync_ack_advanced(&self) {
263        self.sync_ack_advanced_total.fetch_add(1, Relaxed);
264    }
265
266    pub fn inc_connection_accepted(&self) {
267        self.connections_accepted_total.fetch_add(1, Relaxed);
268    }
269
270    pub fn inc_auth_failure(&self) {
271        self.auth_failures_total.fetch_add(1, Relaxed);
272    }
273
274    pub fn inc_tls_failure(&self) {
275        self.tls_handshake_failures_total.fetch_add(1, Relaxed);
276    }
277
278    /// RAII gauge: increments `connections_active` now, decrements on drop —
279    /// correct across every early return or panic in the connection task.
280    pub fn active_guard(self: &Arc<Self>) -> ActiveGuard {
281        self.connections_active.fetch_add(1, Relaxed);
282        ActiveGuard(self.clone())
283    }
284
285    /// RAII gauge: increments `queries_in_flight` now, decrements on drop.
286    pub fn in_flight_guard(self: &Arc<Self>) -> InFlightGuard {
287        self.queries_in_flight.fetch_add(1, Relaxed);
288        InFlightGuard(self.clone())
289    }
290
291    /// Render the full exposition in Prometheus text format (v0.0.4).
292    pub fn render(&self) -> String {
293        let mut out = String::with_capacity(2048);
294
295        // build_info — value is always 1; the version is a label.
296        out.push_str("# HELP powdb_build_info Build information.\n");
297        out.push_str("# TYPE powdb_build_info gauge\n");
298        let _ = writeln!(
299            out,
300            "powdb_build_info{{version=\"{}\"}} 1",
301            escape_label(self.version)
302        );
303
304        gauge_f64(
305            &mut out,
306            "powdb_uptime_seconds",
307            "Seconds since the server started.",
308            self.start.elapsed().as_secs_f64(),
309        );
310        gauge_u64(
311            &mut out,
312            "powdb_connections_active",
313            "Currently open client connections.",
314            self.connections_active.load(Relaxed),
315        );
316        counter(
317            &mut out,
318            "powdb_connections_accepted_total",
319            "Total client connections accepted.",
320            self.connections_accepted_total.load(Relaxed),
321        );
322        counter(
323            &mut out,
324            "powdb_tls_handshake_failures_total",
325            "Total TLS handshakes that failed.",
326            self.tls_handshake_failures_total.load(Relaxed),
327        );
328
329        // queries_total{result} — always emit both label values.
330        out.push_str("# HELP powdb_queries_total Total queries executed, by result.\n");
331        out.push_str("# TYPE powdb_queries_total counter\n");
332        let _ = writeln!(
333            out,
334            "powdb_queries_total{{result=\"ok\"}} {}",
335            self.queries_ok_total.load(Relaxed)
336        );
337        let _ = writeln!(
338            out,
339            "powdb_queries_total{{result=\"error\"}} {}",
340            self.queries_error_total.load(Relaxed)
341        );
342
343        gauge_u64(
344            &mut out,
345            "powdb_queries_in_flight",
346            "Queries currently executing (saturation behind the engine lock).",
347            self.queries_in_flight.load(Relaxed),
348        );
349        counter(
350            &mut out,
351            "powdb_query_timeouts_total",
352            "Total queries whose execution exceeded the configured query timeout threshold.",
353            self.query_timeouts_total.load(Relaxed),
354        );
355        counter(
356            &mut out,
357            "powdb_query_memory_limit_exceeded_total",
358            "Total queries rejected by the per-query memory budget.",
359            self.query_memory_limit_exceeded_total.load(Relaxed),
360        );
361        counter(
362            &mut out,
363            "powdb_auth_failures_total",
364            "Total authentication failures.",
365            self.auth_failures_total.load(Relaxed),
366        );
367
368        // Latency histogram. Derive count and +Inf from the same bucket reads
369        // so +Inf == _count in this exposition no matter what observers do.
370        out.push_str("# HELP powdb_query_duration_seconds Query execution time in seconds.\n");
371        out.push_str("# TYPE powdb_query_duration_seconds histogram\n");
372        let mut cumulative = 0u64;
373        for (i, &bound) in LATENCY_BUCKETS.iter().enumerate() {
374            cumulative += self.latency_buckets[i].load(Relaxed);
375            let _ = writeln!(
376                out,
377                "powdb_query_duration_seconds_bucket{{le=\"{bound}\"}} {cumulative}"
378            );
379        }
380        cumulative += self.latency_buckets[LATENCY_BUCKETS.len()].load(Relaxed);
381        let _ = writeln!(
382            out,
383            "powdb_query_duration_seconds_bucket{{le=\"+Inf\"}} {cumulative}"
384        );
385        let sum_secs = self.latency_sum_nanos.load(Relaxed) as f64 / 1e9;
386        let _ = writeln!(out, "powdb_query_duration_seconds_sum {sum_secs}");
387        let _ = writeln!(out, "powdb_query_duration_seconds_count {cumulative}");
388
389        out.push_str("# HELP powdb_sync_operations_total Total private sync protocol operations, by operation and result.\n");
390        out.push_str("# TYPE powdb_sync_operations_total counter\n");
391        for operation in SyncOperation::ALL {
392            for outcome in SyncOutcome::ALL {
393                let _ = writeln!(
394                    out,
395                    "powdb_sync_operations_total{{operation=\"{}\",result=\"{}\"}} {}",
396                    operation.as_label(),
397                    outcome.as_label(),
398                    self.sync_operations_total[operation.idx()][outcome.idx()].load(Relaxed)
399                );
400            }
401        }
402
403        counter(
404            &mut out,
405            "powdb_sync_pull_units_total",
406            "Total retained units served by private sync pull responses.",
407            self.sync_pull_units_total.load(Relaxed),
408        );
409        counter(
410            &mut out,
411            "powdb_sync_pull_bytes_total",
412            "Total retained-unit wire payload bytes served by private sync pull responses.",
413            self.sync_pull_bytes_total.load(Relaxed),
414        );
415        counter(
416            &mut out,
417            "powdb_sync_ack_advanced_total",
418            "Total sync acknowledgements that advanced a replica cursor.",
419            self.sync_ack_advanced_total.load(Relaxed),
420        );
421
422        out.push_str("# HELP powdb_sync_repair_actions_total Total sync status repair actions returned by operation.\n");
423        out.push_str("# TYPE powdb_sync_repair_actions_total counter\n");
424        for operation in SyncOperation::ALL {
425            for repair in SyncRepairLabel::ALL {
426                let _ = writeln!(
427                    out,
428                    "powdb_sync_repair_actions_total{{operation=\"{}\",repair_action=\"{}\"}} {}",
429                    operation.as_label(),
430                    repair.as_label(),
431                    self.sync_repair_actions_total[operation.idx()][repair.idx()].load(Relaxed)
432                );
433            }
434        }
435
436        out.push_str("# HELP powdb_sync_operation_duration_seconds Private sync protocol operation time in seconds.\n");
437        out.push_str("# TYPE powdb_sync_operation_duration_seconds histogram\n");
438        for operation in SyncOperation::ALL {
439            let mut cumulative = 0u64;
440            for (i, &bound) in LATENCY_BUCKETS.iter().enumerate() {
441                cumulative += self.sync_latency_buckets[operation.idx()][i].load(Relaxed);
442                let _ = writeln!(
443                    out,
444                    "powdb_sync_operation_duration_seconds_bucket{{operation=\"{}\",le=\"{bound}\"}} {cumulative}",
445                    operation.as_label()
446                );
447            }
448            cumulative +=
449                self.sync_latency_buckets[operation.idx()][LATENCY_BUCKETS.len()].load(Relaxed);
450            let _ = writeln!(
451                out,
452                "powdb_sync_operation_duration_seconds_bucket{{operation=\"{}\",le=\"+Inf\"}} {cumulative}",
453                operation.as_label()
454            );
455            let sum_secs = self.sync_latency_sum_nanos[operation.idx()].load(Relaxed) as f64 / 1e9;
456            let _ = writeln!(
457                out,
458                "powdb_sync_operation_duration_seconds_sum{{operation=\"{}\"}} {sum_secs}",
459                operation.as_label()
460            );
461            let _ = writeln!(
462                out,
463                "powdb_sync_operation_duration_seconds_count{{operation=\"{}\"}} {cumulative}",
464                operation.as_label()
465            );
466        }
467
468        out
469    }
470}
471
472/// Decrements `connections_active` when dropped.
473pub struct ActiveGuard(Arc<Metrics>);
474impl Drop for ActiveGuard {
475    fn drop(&mut self) {
476        self.0.connections_active.fetch_sub(1, Relaxed);
477    }
478}
479
480/// Decrements `queries_in_flight` when dropped.
481pub struct InFlightGuard(Arc<Metrics>);
482impl Drop for InFlightGuard {
483    fn drop(&mut self) {
484        self.0.queries_in_flight.fetch_sub(1, Relaxed);
485    }
486}
487
488fn counter(out: &mut String, name: &str, help: &str, value: u64) {
489    let _ = writeln!(out, "# HELP {name} {help}");
490    let _ = writeln!(out, "# TYPE {name} counter");
491    let _ = writeln!(out, "{name} {value}");
492}
493
494fn gauge_u64(out: &mut String, name: &str, help: &str, value: u64) {
495    let _ = writeln!(out, "# HELP {name} {help}");
496    let _ = writeln!(out, "# TYPE {name} gauge");
497    let _ = writeln!(out, "{name} {value}");
498}
499
500fn gauge_f64(out: &mut String, name: &str, help: &str, value: f64) {
501    let _ = writeln!(out, "# HELP {name} {help}");
502    let _ = writeln!(out, "# TYPE {name} gauge");
503    let _ = writeln!(out, "{name} {value}");
504}
505
506/// Escape a Prometheus label value: backslash, double-quote, newline.
507fn escape_label(s: &str) -> String {
508    s.replace('\\', "\\\\")
509        .replace('"', "\\\"")
510        .replace('\n', "\\n")
511}
512
513/// Serve `GET /metrics` over `listener` until the shutdown signal flips. Each
514/// connection is handled on its own task so one slow client can't wedge the
515/// accept loop; the listener drains on SIGINT/SIGTERM via the watch channel.
516pub async fn serve_metrics(
517    listener: TcpListener,
518    metrics: Arc<Metrics>,
519    mut shutdown_rx: watch::Receiver<bool>,
520) {
521    loop {
522        tokio::select! {
523            accepted = listener.accept() => {
524                match accepted {
525                    Ok((stream, _peer)) => {
526                        let m = metrics.clone();
527                        tokio::spawn(async move { handle_scrape(stream, m).await; });
528                    }
529                    Err(e) => debug!(error = %e, "metrics accept error"),
530                }
531            }
532            _ = shutdown_rx.changed() => {
533                if *shutdown_rx.borrow() {
534                    break;
535                }
536            }
537        }
538    }
539}
540
541/// Handle one scrape connection: read just the request line (capped + timed
542/// out), answer `GET /metrics` with the exposition, everything else with 404.
543async fn handle_scrape<S>(mut stream: S, metrics: Arc<Metrics>)
544where
545    S: AsyncRead + AsyncWrite + Unpin,
546{
547    let mut buf: Vec<u8> = Vec::with_capacity(256);
548    let mut chunk = [0u8; 1024];
549
550    let request_line = loop {
551        if let Some(end) = find_line_end(&buf) {
552            break String::from_utf8_lossy(&buf[..end]).into_owned();
553        }
554        if buf.len() >= MAX_REQUEST_BYTES {
555            let _ = respond(&mut stream, 400, "text/plain", "request too large\n").await;
556            return;
557        }
558        match tokio::time::timeout(READ_TIMEOUT, stream.read(&mut chunk)).await {
559            Ok(Ok(0)) => return, // EOF before a complete request line
560            Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
561            Ok(Err(e)) => {
562                debug!(error = %e, "metrics read error");
563                return;
564            }
565            Err(_) => {
566                debug!("metrics read timeout (slow client)");
567                return;
568            }
569        }
570    };
571
572    let mut parts = request_line.split_whitespace();
573    let method = parts.next().unwrap_or("");
574    let path = parts.next().unwrap_or("");
575
576    if method == "GET" && path == "/metrics" {
577        let body = metrics.render();
578        let _ = respond(
579            &mut stream,
580            200,
581            "text/plain; version=0.0.4; charset=utf-8",
582            &body,
583        )
584        .await;
585    } else {
586        let _ = respond(&mut stream, 404, "text/plain", "not found\n").await;
587    }
588}
589
590/// Index just past the request line (position of `\n`, with a trailing `\r`
591/// trimmed). Handles `\r\n` and bare `\n`.
592fn find_line_end(buf: &[u8]) -> Option<usize> {
593    buf.iter().position(|&b| b == b'\n').map(|nl| {
594        if nl > 0 && buf[nl - 1] == b'\r' {
595            nl - 1
596        } else {
597            nl
598        }
599    })
600}
601
602async fn respond<S>(
603    stream: &mut S,
604    status: u16,
605    content_type: &str,
606    body: &str,
607) -> std::io::Result<()>
608where
609    S: AsyncWrite + Unpin,
610{
611    let reason = match status {
612        200 => "OK",
613        400 => "Bad Request",
614        404 => "Not Found",
615        _ => "OK",
616    };
617    let response = format!(
618        "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
619        body.len()
620    );
621    stream.write_all(response.as_bytes()).await?;
622    stream.flush().await
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628
629    #[test]
630    fn render_has_required_families_and_help_type() {
631        let m = Metrics::new();
632        let out = m.render();
633        for name in [
634            "powdb_build_info",
635            "powdb_uptime_seconds",
636            "powdb_connections_active",
637            "powdb_connections_accepted_total",
638            "powdb_tls_handshake_failures_total",
639            "powdb_queries_total",
640            "powdb_queries_in_flight",
641            "powdb_query_timeouts_total",
642            "powdb_query_memory_limit_exceeded_total",
643            "powdb_auth_failures_total",
644            "powdb_query_duration_seconds",
645            "powdb_sync_operations_total",
646            "powdb_sync_pull_units_total",
647            "powdb_sync_pull_bytes_total",
648            "powdb_sync_ack_advanced_total",
649            "powdb_sync_repair_actions_total",
650            "powdb_sync_operation_duration_seconds",
651        ] {
652            assert!(
653                out.contains(&format!("# HELP {name}")),
654                "missing HELP {name}"
655            );
656            assert!(
657                out.contains(&format!("# TYPE {name}")),
658                "missing TYPE {name}"
659            );
660        }
661        // Both label values always present.
662        assert!(out.contains("powdb_queries_total{result=\"ok\"} 0"));
663        assert!(out.contains("powdb_queries_total{result=\"error\"} 0"));
664        assert!(out.contains("powdb_sync_operations_total{operation=\"status\",result=\"ok\"} 0"));
665        assert!(out.contains("powdb_sync_operations_total{operation=\"pull\",result=\"error\"} 0"));
666        assert!(out.contains("powdb_sync_operations_total{operation=\"ack\",result=\"ok\"} 0"));
667        assert!(out.contains(
668            "powdb_sync_repair_actions_total{operation=\"status\",repair_action=\"pull\"} 0"
669        ));
670        assert!(out.contains(
671            "powdb_sync_repair_actions_total{operation=\"pull\",repair_action=\"rebootstrap\"} 0"
672        ));
673        assert!(out.contains("powdb_build_info{version=\""));
674    }
675
676    #[test]
677    fn histogram_buckets_are_cumulative_and_inf_equals_count() {
678        let m = Metrics::new();
679        m.record_query(Duration::from_micros(300), QueryOutcome::Ok); // <= 0.0005
680        m.record_query(Duration::from_millis(3), QueryOutcome::Ok); // <= 0.005
681        m.record_query(Duration::from_secs(10), QueryOutcome::Error); // overflow (> 5s)
682        let out = m.render();
683
684        // le="0.0005" has 1; le="0.005" is cumulative => 2; +Inf => 3 == count.
685        assert!(out.contains("powdb_query_duration_seconds_bucket{le=\"0.0005\"} 1"));
686        assert!(out.contains("powdb_query_duration_seconds_bucket{le=\"0.005\"} 2"));
687        assert!(out.contains("powdb_query_duration_seconds_bucket{le=\"+Inf\"} 3"));
688        assert!(out.contains("powdb_query_duration_seconds_count 3"));
689        // queries_total split: 2 ok, 1 error.
690        assert!(out.contains("powdb_queries_total{result=\"ok\"} 2"));
691        assert!(out.contains("powdb_queries_total{result=\"error\"} 1"));
692    }
693
694    #[test]
695    fn outcome_counters_route_correctly() {
696        let m = Metrics::new();
697        m.record_query(Duration::from_millis(1), QueryOutcome::Timeout);
698        m.record_query(Duration::from_millis(1), QueryOutcome::MemoryLimit);
699        let out = m.render();
700        // Both count as errors, plus their specific counters.
701        assert!(out.contains("powdb_queries_total{result=\"error\"} 2"));
702        assert!(out.contains("powdb_query_timeouts_total 1"));
703        assert!(out.contains("powdb_query_memory_limit_exceeded_total 1"));
704    }
705
706    #[test]
707    fn sync_operation_metrics_route_and_bucket_correctly() {
708        let m = Metrics::new();
709        m.record_sync_operation(
710            SyncOperation::Status,
711            Duration::from_micros(400),
712            SyncOutcome::Ok,
713        );
714        m.record_sync_operation(
715            SyncOperation::Pull,
716            Duration::from_millis(2),
717            SyncOutcome::Ok,
718        );
719        m.record_sync_operation(
720            SyncOperation::Ack,
721            Duration::from_secs(7),
722            SyncOutcome::Error,
723        );
724        m.record_sync_pull_payload(3, 1234);
725        m.record_sync_repair_action(SyncOperation::Status, SyncRepairLabel::Pull);
726        m.record_sync_repair_action(SyncOperation::Pull, SyncRepairLabel::Rebootstrap);
727        m.inc_sync_ack_advanced();
728
729        let out = m.render();
730        assert!(out.contains("powdb_sync_operations_total{operation=\"status\",result=\"ok\"} 1"));
731        assert!(out.contains("powdb_sync_operations_total{operation=\"pull\",result=\"ok\"} 1"));
732        assert!(out.contains("powdb_sync_operations_total{operation=\"ack\",result=\"error\"} 1"));
733        assert!(out.contains("powdb_sync_pull_units_total 3"));
734        assert!(out.contains("powdb_sync_pull_bytes_total 1234"));
735        assert!(out.contains("powdb_sync_ack_advanced_total 1"));
736        assert!(out.contains(
737            "powdb_sync_repair_actions_total{operation=\"status\",repair_action=\"pull\"} 1"
738        ));
739        assert!(out.contains(
740            "powdb_sync_repair_actions_total{operation=\"pull\",repair_action=\"rebootstrap\"} 1"
741        ));
742        assert!(out.contains(
743            "powdb_sync_operation_duration_seconds_bucket{operation=\"status\",le=\"0.0005\"} 1"
744        ));
745        assert!(out.contains(
746            "powdb_sync_operation_duration_seconds_bucket{operation=\"ack\",le=\"+Inf\"} 1"
747        ));
748        assert!(out.contains("powdb_sync_operation_duration_seconds_count{operation=\"pull\"} 1"));
749    }
750
751    #[test]
752    fn guards_increment_and_decrement() {
753        let m = Arc::new(Metrics::new());
754        {
755            let _a = m.active_guard();
756            let _b = m.active_guard();
757            let _f = m.in_flight_guard();
758            assert!(m.render().contains("powdb_connections_active 2"));
759            assert!(m.render().contains("powdb_queries_in_flight 1"));
760        }
761        assert!(m.render().contains("powdb_connections_active 0"));
762        assert!(m.render().contains("powdb_queries_in_flight 0"));
763    }
764
765    #[test]
766    fn escape_label_escapes_special_chars() {
767        assert_eq!(escape_label(r#"a\b"c"#), r#"a\\b\"c"#);
768        assert_eq!(escape_label("a\nb"), "a\\nb");
769        assert_eq!(escape_label("0.5.1"), "0.5.1");
770    }
771
772    #[test]
773    fn find_line_end_handles_crlf_lf_and_none() {
774        assert_eq!(find_line_end(b"GET / HTTP/1.1\r\n"), Some(14));
775        assert_eq!(find_line_end(b"GET / HTTP/1.1\n"), Some(14));
776        assert_eq!(find_line_end(b"GET / HTTP/1.1"), None);
777        assert_eq!(find_line_end(b"\n"), Some(0));
778    }
779
780    #[test]
781    fn concurrent_record_query_is_consistent_at_quiescence() {
782        use std::thread;
783        let m = Arc::new(Metrics::new());
784        let mut handles = Vec::new();
785        for _ in 0..8 {
786            let m = m.clone();
787            handles.push(thread::spawn(move || {
788                for _ in 0..1000 {
789                    m.record_query(Duration::from_millis(2), QueryOutcome::Ok);
790                }
791            }));
792        }
793        // Concurrent scrapes must never panic or produce +Inf < count.
794        for _ in 0..50 {
795            let _ = m.render();
796        }
797        for h in handles {
798            h.join().unwrap();
799        }
800        let out = m.render();
801        assert!(out.contains("powdb_queries_total{result=\"ok\"} 8000"));
802        assert!(out.contains("powdb_query_duration_seconds_count 8000"));
803        assert!(out.contains("powdb_query_duration_seconds_bucket{le=\"+Inf\"} 8000"));
804    }
805
806    #[tokio::test]
807    async fn handle_scrape_serves_metrics_and_404s_other_paths() {
808        use tokio::io::{AsyncReadExt, AsyncWriteExt};
809
810        // GET /metrics -> 200 + exposition.
811        let (mut client, server) = tokio::io::duplex(8192);
812        let m = Arc::new(Metrics::new());
813        let task = tokio::spawn(handle_scrape(server, m));
814        client
815            .write_all(b"GET /metrics HTTP/1.1\r\nHost: x\r\n\r\n")
816            .await
817            .unwrap();
818        let mut resp = String::new();
819        client.read_to_string(&mut resp).await.unwrap();
820        task.await.unwrap();
821        assert!(resp.starts_with("HTTP/1.1 200 OK"), "resp: {resp}");
822        assert!(resp.contains("powdb_build_info"));
823
824        // Unknown path -> 404.
825        let (mut client, server) = tokio::io::duplex(8192);
826        let m = Arc::new(Metrics::new());
827        let task = tokio::spawn(handle_scrape(server, m));
828        client
829            .write_all(b"GET /nope HTTP/1.1\r\n\r\n")
830            .await
831            .unwrap();
832        let mut resp = String::new();
833        client.read_to_string(&mut resp).await.unwrap();
834        task.await.unwrap();
835        assert!(resp.starts_with("HTTP/1.1 404 Not Found"), "resp: {resp}");
836    }
837
838    #[tokio::test]
839    async fn handle_scrape_rejects_garbage_without_panicking() {
840        use tokio::io::{AsyncReadExt, AsyncWriteExt};
841        let (mut client, server) = tokio::io::duplex(8192);
842        let m = Arc::new(Metrics::new());
843        let task = tokio::spawn(handle_scrape(server, m));
844        client
845            .write_all(b"\x00\x01\x02 garbage\r\n\r\n")
846            .await
847            .unwrap();
848        let mut resp = String::new();
849        client.read_to_string(&mut resp).await.unwrap();
850        task.await.unwrap();
851        assert!(resp.starts_with("HTTP/1.1 404"), "resp: {resp}");
852    }
853}