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
152 auth_failures_total: AtomicU64,
153
154 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 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 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 pub fn active_guard(self: &Arc<Self>) -> ActiveGuard {
281 self.connections_active.fetch_add(1, Relaxed);
282 ActiveGuard(self.clone())
283 }
284
285 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 pub fn render(&self) -> String {
293 let mut out = String::with_capacity(2048);
294
295 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 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 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
472pub 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
480pub 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
506fn escape_label(s: &str) -> String {
508 s.replace('\\', "\\\\")
509 .replace('"', "\\\"")
510 .replace('\n', "\\n")
511}
512
513pub 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
541async 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, 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
590fn 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 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); m.record_query(Duration::from_millis(3), QueryOutcome::Ok); m.record_query(Duration::from_secs(10), QueryOutcome::Error); let out = m.render();
683
684 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 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 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 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 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 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}