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