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];
26const SYNC_OPERATION_COUNT: usize = 3;
27const SYNC_OUTCOME_COUNT: usize = 2;
28const SYNC_REPAIR_ACTION_COUNT: usize = 4;
29
30const MAX_REQUEST_BYTES: usize = 8 * 1024;
33
34const READ_TIMEOUT: Duration = Duration::from_secs(5);
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum QueryOutcome {
40 Ok,
41 Error,
42 Timeout,
43 MemoryLimit,
44}
45
46#[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#[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#[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
136pub 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 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 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 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 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 pub fn active_guard(self: &Arc<Self>) -> ActiveGuard {
291 self.connections_active.fetch_add(1, Relaxed);
292 ActiveGuard(self.clone())
293 }
294
295 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 pub fn render(&self) -> String {
303 let mut out = String::with_capacity(2048);
304
305 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 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 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
488pub 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
496pub 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
522fn escape_label(s: &str) -> String {
524 s.replace('\\', "\\\\")
525 .replace('"', "\\\"")
526 .replace('\n', "\\n")
527}
528
529pub 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
557async 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, 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
606fn 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 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); m.record_query(Duration::from_millis(3), QueryOutcome::Ok); m.record_query(Duration::from_secs(10), QueryOutcome::Error); let out = m.render();
700
701 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 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 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 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 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 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 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}