Skip to main content

pharos_server/
metrics.rs

1//! The custom encoder matches Go floats, which `prometheus` 0.14 does not.
2
3use std::{
4    collections::BTreeMap,
5    fmt::Write,
6    sync::{
7        Arc, Mutex,
8        atomic::{AtomicBool, AtomicU64, Ordering},
9    },
10};
11
12mod kubernetes;
13pub use kubernetes::{KubernetesMetrics, KubernetesRequestMetrics};
14
15// IANA DNS Parameters §Resource Record (RR) TYPEs: these values identify DNS query types.
16const TYPES: &[(u16, &str)] = &[
17    (1, "A"),
18    (28, "AAAA"),
19    (5, "CNAME"),
20    (48, "DNSKEY"),
21    (43, "DS"),
22    (15, "MX"),
23    (50, "NSEC3"),
24    (47, "NSEC"),
25    (2, "NS"),
26    (12, "PTR"),
27    (46, "RRSIG"),
28    (6, "SOA"),
29    (33, "SRV"),
30    (16, "TXT"),
31    (65, "HTTPS"),
32    (64, "SVCB"),
33    (251, "IXFR"),
34    (252, "AXFR"),
35    (255, "ANY"),
36];
37const RESPONSE_CODES: usize = 4096;
38pub const WRITER_METADATA: &str = "prometheus/writer";
39const TIME_BUCKETS: &[f64] = &[
40    0.00025, 0.0005, 0.001, 0.002, 0.004, 0.008, 0.016, 0.032, 0.064, 0.128, 0.256, 0.512, 1.024,
41    2.048, 4.096, 8.192,
42];
43const HEALTH_BUCKETS: &[f64] = &[0.00025, 0.0025, 0.025, 0.25, 2.5];
44const SIZE_BUCKETS: &[f64] = &[
45    0., 100., 200., 300., 400., 511., 1023., 2047., 4095., 8291., 16000., 32000., 48000., 64000.,
46];
47
48type Key = (String, String, String);
49type StatusKey = (String, String, String);
50
51#[derive(Clone)]
52pub struct Registry(Arc<RegistryInner>);
53
54struct RegistryInner {
55    version: String,
56    revision: String,
57    rustc: String,
58    blocks: Mutex<BTreeMap<Key, Arc<Block>>>,
59    statuses: Mutex<BTreeMap<StatusKey, Arc<AtomicU64>>>,
60    proxies: Mutex<BTreeMap<(String, String), Arc<Proxy>>>,
61    caches: Mutex<BTreeMap<Key, Arc<Cache>>>,
62    hosts: Mutex<BTreeMap<String, Arc<HostsGauge>>>,
63    hosts_timestamp: Arc<AtomicU64>,
64    forward: Arc<ForwardCounters>,
65    forward_enabled: AtomicBool,
66    local: Arc<AtomicU64>,
67    local_enabled: AtomicBool,
68    process: AtomicBool,
69    health_enabled: AtomicBool,
70    health_duration: Histogram,
71    health_failures: AtomicU64,
72    reload_enabled: AtomicBool,
73    reload_failures: AtomicU64,
74    reload_hash: Mutex<Option<String>>,
75    kubernetes: KubernetesMetrics,
76}
77
78#[derive(Default)]
79struct ForwardCounters {
80    healthcheck_broken: AtomicU64,
81    concurrent_rejects: AtomicU64,
82}
83
84/// Process-wide forward counters, resolved once during setup.
85#[derive(Clone)]
86pub struct ForwardMetrics(Arc<ForwardCounters>);
87
88/// Cache metrics with labels resolved once during setup.
89#[derive(Clone)]
90pub struct CacheMetrics(Arc<Cache>);
91
92#[derive(Debug, Default)]
93struct HostsGauge {
94    entries: AtomicU64,
95    initialized: AtomicBool,
96}
97
98/// Hosts metrics resolved once for a hosts file.
99#[derive(Clone, Debug)]
100pub struct HostsMetrics {
101    gauge: Arc<HostsGauge>,
102    timestamp: Arc<AtomicU64>,
103}
104
105impl HostsMetrics {
106    pub fn update(&self, entries: usize, timestamp: f64) {
107        self.gauge.entries.store(entries as u64, Ordering::Relaxed);
108        self.gauge.initialized.store(true, Ordering::Relaxed);
109        self.timestamp.store(timestamp.to_bits(), Ordering::Relaxed);
110    }
111}
112
113#[derive(Default)]
114struct Cache {
115    entries: [AtomicU64; 2],
116    hits: [AtomicU64; 2],
117    evictions: [AtomicU64; 2],
118    requests: AtomicU64,
119    misses: AtomicU64,
120    prefetches: AtomicU64,
121    drops: AtomicU64,
122    served_stale: AtomicU64,
123}
124
125impl CacheMetrics {
126    pub fn request(&self) {
127        self.0.requests.fetch_add(1, Ordering::Relaxed);
128    }
129
130    pub fn hit(&self, denial: bool) {
131        self.0.hits[usize::from(denial)].fetch_add(1, Ordering::Relaxed);
132    }
133
134    pub fn miss(&self) {
135        self.0.misses.fetch_add(1, Ordering::Relaxed);
136    }
137
138    pub fn prefetch(&self) {
139        self.0.prefetches.fetch_add(1, Ordering::Relaxed);
140    }
141
142    pub fn drop_response(&self) {
143        self.0.drops.fetch_add(1, Ordering::Relaxed);
144    }
145
146    pub fn served_stale(&self) {
147        self.0.served_stale.fetch_add(1, Ordering::Relaxed);
148    }
149
150    pub fn evict(&self, denial: bool) {
151        self.0.evictions[usize::from(denial)].fetch_add(1, Ordering::Relaxed);
152    }
153
154    pub fn entries(&self, denial: bool, count: usize) {
155        self.0.entries[usize::from(denial)].store(count as u64, Ordering::Relaxed);
156    }
157}
158
159impl ForwardMetrics {
160    pub fn healthcheck_broken(&self) {
161        self.0.healthcheck_broken.fetch_add(1, Ordering::Relaxed);
162    }
163
164    pub fn concurrent_reject(&self) {
165        self.0.concurrent_rejects.fetch_add(1, Ordering::Relaxed);
166    }
167}
168
169#[derive(Clone)]
170pub struct BlockMetrics(Arc<Block>);
171
172#[derive(Clone)]
173pub struct TransportStatus(Arc<AtomicU64>);
174
175/// Metrics for an Upstream, shared by identical proxy name and address labels.
176#[derive(Clone)]
177pub struct ProxyMetrics(Arc<Proxy>);
178
179/// A request duration series with its RCODE label resolved ahead of recording.
180#[derive(Clone)]
181pub struct ProxyRequestDuration(Arc<Histogram>);
182
183/// Connection cache counters with their protocol label resolved ahead of recording.
184#[derive(Clone)]
185pub struct ProxyConnectionCache(Arc<ProxyCache>);
186
187struct Proxy {
188    proxy_name: String,
189    to: String,
190    durations: Mutex<BTreeMap<String, Arc<Histogram>>>,
191    caches: Mutex<BTreeMap<String, Arc<ProxyCache>>>,
192    healthcheck_failures: AtomicU64,
193}
194
195#[derive(Default)]
196struct ProxyCache {
197    hits: AtomicU64,
198    misses: AtomicU64,
199}
200
201impl ProxyMetrics {
202    /// Resolve an RCODE label once before observing request durations.
203    pub fn request_duration(&self, rcode: &str) -> ProxyRequestDuration {
204        ProxyRequestDuration(Arc::clone(
205            self.0
206                .durations
207                .lock()
208                .unwrap()
209                .entry(rcode.into())
210                .or_insert_with(|| Arc::new(Histogram::new(TIME_BUCKETS))),
211        ))
212    }
213
214    /// Resolve a protocol label once before recording connection cache events.
215    pub fn connection_cache(&self, proto: &str) -> ProxyConnectionCache {
216        ProxyConnectionCache(Arc::clone(
217            self.0
218                .caches
219                .lock()
220                .unwrap()
221                .entry(proto.into())
222                .or_default(),
223        ))
224    }
225
226    pub fn healthcheck_failure(&self) {
227        self.0.healthcheck_failures.fetch_add(1, Ordering::Relaxed);
228    }
229}
230
231impl ProxyRequestDuration {
232    pub fn observe(&self, seconds: f64) {
233        self.0.observe(TIME_BUCKETS, seconds);
234    }
235}
236
237impl ProxyConnectionCache {
238    pub fn hit(&self) {
239        self.0.hits.fetch_add(1, Ordering::Relaxed);
240    }
241
242    pub fn miss(&self) {
243        self.0.misses.fetch_add(1, Ordering::Relaxed);
244    }
245}
246
247impl TransportStatus {
248    pub fn increment(&self) {
249        self.0.fetch_add(1, Ordering::Relaxed);
250    }
251}
252
253#[derive(Clone)]
254pub struct PluginMetrics {
255    block: Arc<Block>,
256    responses: Arc<Vec<AtomicU64>>,
257}
258
259struct Block {
260    server: String,
261    zone: String,
262    view: String,
263    requests: Vec<AtomicU64>,
264    do_requests: AtomicU64,
265    duration: Histogram,
266    request_size: [Histogram; 2],
267    response_size: [Histogram; 2],
268    default_responses: Arc<Vec<AtomicU64>>,
269    plugins: Mutex<BTreeMap<String, Arc<Vec<AtomicU64>>>>,
270    enabled: Mutex<BTreeMap<String, bool>>,
271}
272
273struct Histogram {
274    buckets: Vec<AtomicU64>,
275    sum: AtomicU64,
276}
277
278impl Histogram {
279    fn new(bounds: &[f64]) -> Self {
280        Self {
281            buckets: counters(bounds.len() + 1),
282            sum: AtomicU64::new(0),
283        }
284    }
285
286    fn observe(&self, bounds: &[f64], value: f64) {
287        let index = bounds
288            .iter()
289            .position(|bound| value <= *bound)
290            .unwrap_or(bounds.len());
291        let mut bits = self.sum.load(Ordering::Relaxed);
292        loop {
293            let next = (f64::from_bits(bits) + value).to_bits();
294            match self
295                .sum
296                .compare_exchange_weak(bits, next, Ordering::Relaxed, Ordering::Relaxed)
297            {
298                Ok(_) => break,
299                Err(actual) => bits = actual,
300            }
301        }
302        self.buckets[index].fetch_add(1, Ordering::Relaxed);
303    }
304
305    fn count(&self) -> u64 {
306        // shortcut: scrape snapshots can race observations until histogram snapshots are synchronized.
307        self.buckets.iter().map(|n| n.load(Ordering::Relaxed)).sum()
308    }
309}
310
311fn counters(count: usize) -> Vec<AtomicU64> {
312    (0..count).map(|_| AtomicU64::new(0)).collect()
313}
314
315impl Registry {
316    pub fn new(version: &str, revision: &str, rustc: &str) -> Self {
317        Self(Arc::new(RegistryInner {
318            version: version.into(),
319            revision: revision.into(),
320            rustc: rustc.into(),
321            blocks: Mutex::new(BTreeMap::new()),
322            statuses: Mutex::new(BTreeMap::new()),
323            proxies: Mutex::new(BTreeMap::new()),
324            caches: Mutex::new(BTreeMap::new()),
325            hosts: Mutex::new(BTreeMap::new()),
326            hosts_timestamp: Arc::new(AtomicU64::new(0)),
327            forward: Arc::new(ForwardCounters::default()),
328            forward_enabled: AtomicBool::new(false),
329            local: Arc::new(AtomicU64::new(0)),
330            local_enabled: AtomicBool::new(false),
331            process: AtomicBool::new(false),
332            health_enabled: AtomicBool::new(false),
333            health_duration: Histogram::new(HEALTH_BUCKETS),
334            health_failures: AtomicU64::new(0),
335            reload_enabled: AtomicBool::new(false),
336            reload_failures: AtomicU64::new(0),
337            reload_hash: Mutex::new(None),
338            kubernetes: KubernetesMetrics::default(),
339        }))
340    }
341
342    pub fn block(&self, server: &str, zone: &str, view: &str) -> BlockMetrics {
343        let key = (server.to_owned(), zone.to_owned(), view.to_owned());
344        let mut blocks = self.0.blocks.lock().unwrap();
345        BlockMetrics(Arc::clone(blocks.entry(key).or_insert_with(|| {
346            let default_responses = Arc::new(counters(RESPONSE_CODES));
347            let mut plugins = BTreeMap::new();
348            plugins.insert(String::new(), Arc::clone(&default_responses));
349            Arc::new(Block {
350                server: server.into(),
351                zone: zone.into(),
352                view: view.into(),
353                requests: counters((TYPES.len() + 1) * 4),
354                do_requests: AtomicU64::new(0),
355                duration: Histogram::new(TIME_BUCKETS),
356                request_size: std::array::from_fn(|_| Histogram::new(SIZE_BUCKETS)),
357                response_size: std::array::from_fn(|_| Histogram::new(SIZE_BUCKETS)),
358                default_responses,
359                plugins: Mutex::new(plugins),
360                enabled: Mutex::new(BTreeMap::new()),
361            })
362        })))
363    }
364
365    /// Resolve cache series once before recording events.
366    pub fn cache(&self, server: &str, zones: &str, view: &str) -> CacheMetrics {
367        let mut caches = self.0.caches.lock().unwrap();
368        CacheMetrics(Arc::clone(
369            caches
370                .entry((server.into(), zones.into(), view.into()))
371                .or_default(),
372        ))
373    }
374
375    pub fn hosts(&self, path: &str) -> HostsMetrics {
376        let gauge = Arc::clone(self.0.hosts.lock().unwrap().entry(path.into()).or_default());
377        HostsMetrics {
378            gauge,
379            timestamp: Arc::clone(&self.0.hosts_timestamp),
380        }
381    }
382
383    pub fn kubernetes(&self) -> KubernetesMetrics {
384        self.0.kubernetes.clone()
385    }
386
387    /// Resolve metrics for a proxy and Upstream address before recording events.
388    pub fn proxy(&self, proxy_name: &str, to: &str) -> ProxyMetrics {
389        let mut proxies = self.0.proxies.lock().unwrap();
390        ProxyMetrics(Arc::clone(
391            proxies
392                .entry((proxy_name.into(), to.into()))
393                .or_insert_with(|| {
394                    Arc::new(Proxy {
395                        proxy_name: proxy_name.into(),
396                        to: to.into(),
397                        durations: Mutex::new(BTreeMap::new()),
398                        caches: Mutex::new(BTreeMap::new()),
399                        healthcheck_failures: AtomicU64::new(0),
400                    })
401                }),
402        ))
403    }
404
405    /// Register and share forward's unlabelled counters.
406    pub fn forward(&self) -> ForwardMetrics {
407        self.0.forward_enabled.store(true, Ordering::Relaxed);
408        ForwardMetrics(Arc::clone(&self.0.forward))
409    }
410
411    /// Resolve the process-wide, unlabelled local counter at setup.
412    pub fn local(&self) -> Arc<AtomicU64> {
413        self.0.local_enabled.store(true, Ordering::Relaxed);
414        Arc::clone(&self.0.local)
415    }
416
417    /// Include process metrics from the operating system at scrape time.
418    pub fn enable_process_metrics(&self) {
419        self.0.process.store(true, Ordering::Relaxed);
420    }
421
422    pub fn enable_health_metrics(&self) {
423        self.0.health_enabled.store(true, Ordering::Relaxed);
424    }
425
426    pub fn reload_failed(&self) {
427        self.0.reload_enabled.store(true, Ordering::Relaxed);
428        self.0.reload_failures.fetch_add(1, Ordering::Relaxed);
429    }
430
431    pub fn enable_reload_metrics(&self) {
432        self.0.reload_enabled.store(true, Ordering::Relaxed);
433    }
434
435    pub fn reload_version(&self, hash: String) {
436        self.0.reload_enabled.store(true, Ordering::Relaxed);
437        *self.0.reload_hash.lock().unwrap() = Some(hash);
438    }
439
440    pub fn observe_health(&self, seconds: f64, failed: bool) {
441        self.0.health_duration.observe(HEALTH_BUCKETS, seconds);
442        if failed {
443            self.0.health_failures.fetch_add(1, Ordering::Relaxed);
444        }
445    }
446
447    /// Resolve a transport status vector ("https", "https3", or "quic").
448    pub fn transport_status(&self, transport: &str, server: &str, status: &str) -> TransportStatus {
449        assert!(matches!(transport, "https" | "https3" | "quic"));
450        let counter = self
451            .0
452            .statuses
453            .lock()
454            .unwrap()
455            .entry((transport.into(), server.into(), status.into()))
456            .or_default()
457            .clone();
458        TransportStatus(counter)
459    }
460
461    /// Append text to `out`; callers can append process metrics to the same buffer.
462    pub fn encode(&self, out: &mut String) {
463        self.0.kubernetes.encode(out);
464        if self.0.local_enabled.load(Ordering::Relaxed) {
465            header(
466                out,
467                "local_localhost_requests_total",
468                "Counter of special localhost subdomain requests.",
469                "counter",
470            );
471            writeln!(
472                out,
473                "pharos_local_localhost_requests_total {}",
474                self.0.local.load(Ordering::Relaxed)
475            )
476            .unwrap();
477        }
478        if self.0.reload_enabled.load(Ordering::Relaxed) {
479            header(
480                out,
481                "reload_failed_total",
482                "Counter of the number of failed reload attempts.",
483                "counter",
484            );
485            writeln!(
486                out,
487                "pharos_reload_failed_total {}",
488                self.0.reload_failures.load(Ordering::Relaxed)
489            )
490            .unwrap();
491            if let Some(hash) = self.0.reload_hash.lock().unwrap().as_ref() {
492                header(
493                    out,
494                    "reload_version_info",
495                    "A metric with a constant '1' value labeled by hash, and value which type of hash generated.",
496                    "gauge",
497                );
498                writeln!(
499                    out,
500                    "pharos_reload_version_info{{hash=\"sha512\",value=\"{hash}\"}} 1"
501                )
502                .unwrap();
503            }
504        }
505        if self.0.health_enabled.load(Ordering::Relaxed) {
506            let name = "health_request_duration_seconds";
507            header(
508                out,
509                name,
510                "Histogram of the time (in seconds) each request took.",
511                "histogram",
512            );
513            let histogram = &self.0.health_duration;
514            let mut count = 0;
515            for (index, bucket) in histogram.buckets.iter().enumerate() {
516                count += bucket.load(Ordering::Relaxed);
517                let bound = HEALTH_BUCKETS
518                    .get(index)
519                    .map_or_else(|| "+Inf".into(), |v| float(*v));
520                writeln!(
521                    out,
522                    "pharos_{name}_bucket{{le=\"{bound}\"}} {}",
523                    float(count as f64)
524                )
525                .unwrap();
526            }
527            writeln!(
528                out,
529                "pharos_{name}_sum {}",
530                float(f64::from_bits(histogram.sum.load(Ordering::Relaxed)))
531            )
532            .unwrap();
533            writeln!(out, "pharos_{name}_count {}", float(count as f64)).unwrap();
534            header(
535                out,
536                "health_request_failures_total",
537                "The number of times the health check failed.",
538                "counter",
539            );
540            writeln!(
541                out,
542                "pharos_health_request_failures_total {}",
543                float(self.0.health_failures.load(Ordering::Relaxed) as f64)
544            )
545            .unwrap();
546        }
547        header(
548            out,
549            "build_info",
550            "A metric with a constant '1' value labeled by version, revision, and rustc from which Pharos was built.",
551            "gauge",
552        );
553        out.push_str("pharos_build_info{");
554        labels(
555            out,
556            &[
557                ("revision", &self.0.revision),
558                ("rustc", &self.0.rustc),
559                ("version", &self.0.version),
560            ],
561        );
562        out.push_str("} 1\n");
563
564        self.encode_caches(out);
565        let hosts = self.0.hosts.lock().unwrap();
566        if !hosts.is_empty() {
567            if hosts
568                .values()
569                .any(|gauge| gauge.initialized.load(Ordering::Relaxed))
570            {
571                header(
572                    out,
573                    "hosts_entries",
574                    "The combined number of entries in hosts and Corefile.",
575                    "gauge",
576                );
577                for (path, gauge) in hosts
578                    .iter()
579                    .filter(|(_, gauge)| gauge.initialized.load(Ordering::Relaxed))
580                {
581                    sample(
582                        out,
583                        "hosts_entries",
584                        &[("hostsfile", path)],
585                        gauge.entries.load(Ordering::Relaxed),
586                    );
587                }
588            }
589            header(
590                out,
591                "hosts_reload_timestamp_seconds",
592                "The timestamp of the last reload of hosts file.",
593                "gauge",
594            );
595            writeln!(
596                out,
597                "pharos_hosts_reload_timestamp_seconds {}",
598                float(f64::from_bits(
599                    self.0.hosts_timestamp.load(Ordering::Relaxed)
600                ))
601            )
602            .unwrap();
603        }
604        drop(hosts);
605
606        let blocks = self.0.blocks.lock().unwrap();
607        let status = self.0.statuses.lock().unwrap();
608        if blocks
609            .values()
610            .any(|b| b.do_requests.load(Ordering::Relaxed) != 0)
611        {
612            header(
613                out,
614                "dns_do_requests_total",
615                "Counter of DNS requests with DO bit set per zone.",
616                "counter",
617            );
618            let mut sorted = SortedSamples::default();
619            for b in blocks.values() {
620                let n = b.do_requests.load(Ordering::Relaxed);
621                if n != 0 {
622                    sorted.sample("dns_do_requests_total", &b.labels(), n);
623                }
624            }
625            sorted.write(out);
626        }
627        for (transport, help) in [
628            (
629                "https3",
630                "Counter of DoH3 responses per server and http status code.",
631            ),
632            (
633                "https",
634                "Counter of DoH responses per server and http status code.",
635            ),
636            (
637                "quic",
638                "Counter of DoQ responses per server and QUIC application code.",
639            ),
640        ] {
641            if status.keys().any(|(kind, _, _)| kind == transport) {
642                let name = format!("dns_{transport}_responses_total");
643                header(out, &name, help, "counter");
644                for ((kind, server, code), n) in
645                    status.iter().filter(|((kind, _, _), _)| kind == transport)
646                {
647                    let _ = kind;
648                    sample(
649                        out,
650                        &name,
651                        &[("server", server), ("status", code)],
652                        n.load(Ordering::Relaxed),
653                    );
654                }
655            }
656        }
657        histogram_family(
658            out,
659            &blocks,
660            "dns_request_duration_seconds",
661            "Histogram of the time (in seconds) each request took per zone.",
662            TIME_BUCKETS,
663            |b| vec![(None, &b.duration)],
664        );
665        histogram_family(
666            out,
667            &blocks,
668            "dns_request_size_bytes",
669            "Size of the EDNS0 UDP buffer in bytes (64K for TCP) per zone and protocol.",
670            SIZE_BUCKETS,
671            |b| {
672                vec![
673                    (Some("tcp"), &b.request_size[1]),
674                    (Some("udp"), &b.request_size[0]),
675                ]
676            },
677        );
678        if blocks
679            .values()
680            .any(|b| b.requests.iter().any(|n| n.load(Ordering::Relaxed) != 0))
681        {
682            header(
683                out,
684                "dns_requests_total",
685                "Counter of DNS requests made per zone, protocol and family.",
686                "counter",
687            );
688            let mut sorted = SortedSamples::default();
689            for b in blocks.values() {
690                for family in 0..2 {
691                    for proto in 0..2 {
692                        for typ in 0..=TYPES.len() {
693                            let n = b.requests[(family * 2 + proto) * (TYPES.len() + 1) + typ]
694                                .load(Ordering::Relaxed);
695                            if n == 0 {
696                                continue;
697                            }
698                            let ty = TYPES.get(typ).map_or("other", |(_, name)| *name);
699                            sorted.sample(
700                                "dns_requests_total",
701                                &[
702                                    ("family", if family == 0 { "1" } else { "2" }),
703                                    ("proto", if proto == 0 { "udp" } else { "tcp" }),
704                                    ("server", &b.server),
705                                    ("type", ty),
706                                    ("view", &b.view),
707                                    ("zone", &b.zone),
708                                ],
709                                n,
710                            );
711                        }
712                    }
713                }
714            }
715            sorted.write(out);
716        }
717        histogram_family(
718            out,
719            &blocks,
720            "dns_response_size_bytes",
721            "Size of the returned response in bytes.",
722            SIZE_BUCKETS,
723            |b| {
724                vec![
725                    (Some("tcp"), &b.response_size[1]),
726                    (Some("udp"), &b.response_size[0]),
727                ]
728            },
729        );
730        if blocks.values().any(|b| {
731            b.plugins
732                .lock()
733                .unwrap()
734                .values()
735                .any(|v| v.iter().any(|n| n.load(Ordering::Relaxed) != 0))
736        }) {
737            header(
738                out,
739                "dns_responses_total",
740                "Counter of response status codes.",
741                "counter",
742            );
743            let mut sorted = SortedSamples::default();
744            for b in blocks.values() {
745                for (plugin, counts) in b.plugins.lock().unwrap().iter() {
746                    for (code, counter) in counts.iter().enumerate() {
747                        let n = counter.load(Ordering::Relaxed);
748                        if n != 0 {
749                            let rcode = rcode(code as u16);
750                            sorted.sample(
751                                "dns_responses_total",
752                                &[
753                                    ("plugin", plugin),
754                                    ("rcode", &rcode),
755                                    ("server", &b.server),
756                                    ("view", &b.view),
757                                    ("zone", &b.zone),
758                                ],
759                                n,
760                            );
761                        }
762                    }
763                }
764            }
765            sorted.write(out);
766        }
767        header(
768            out,
769            "panics_total",
770            "A metrics that counts the number of panics.",
771            "counter",
772        );
773        writeln!(
774            out,
775            "pharos_panics_total {}",
776            float(crate::panics_total() as f64)
777        )
778        .unwrap();
779        if blocks
780            .values()
781            .any(|b| !b.enabled.lock().unwrap().is_empty())
782        {
783            header(
784                out,
785                "plugin_enabled",
786                "A metric that indicates whether a plugin is enabled on per server and zone basis.",
787                "gauge",
788            );
789            let mut sorted = SortedSamples::default();
790            for b in blocks.values() {
791                for (name, enabled) in b.enabled.lock().unwrap().iter() {
792                    sorted.sample(
793                        "plugin_enabled",
794                        &[
795                            ("name", name),
796                            ("server", &b.server),
797                            ("view", &b.view),
798                            ("zone", &b.zone),
799                        ],
800                        u64::from(*enabled),
801                    );
802                }
803            }
804            sorted.write(out);
805        }
806        self.encode_proxies(out);
807        if self.0.forward_enabled.load(Ordering::Relaxed) {
808            for (name, help, counter) in [
809                (
810                    "forward_healthcheck_broken_total",
811                    "Counter of the number of complete failures of the healthchecks.",
812                    &self.0.forward.healthcheck_broken,
813                ),
814                (
815                    "forward_max_concurrent_rejects_total",
816                    "Counter of the number of queries rejected because the concurrent queries were at maximum.",
817                    &self.0.forward.concurrent_rejects,
818                ),
819            ] {
820                header(out, name, help, "counter");
821                writeln!(out, "pharos_{name} {}", counter.load(Ordering::Relaxed)).unwrap();
822            }
823        }
824        if self.0.process.load(Ordering::Relaxed) {
825            crate::process_metrics::encode_process(out);
826        }
827    }
828
829    fn encode_caches(&self, out: &mut String) {
830        let caches = self.0.caches.lock().unwrap();
831        if caches.is_empty() {
832            return;
833        }
834        for (name, help, kind, typed, get) in [
835            (
836                "cache_drops_total",
837                "The number responses that are not cached, because the reply is malformed.",
838                "counter",
839                false,
840                (|c: &Cache| std::slice::from_ref(&c.drops)) as fn(&Cache) -> &[AtomicU64],
841            ),
842            (
843                "cache_entries",
844                "The number of elements in the cache.",
845                "gauge",
846                true,
847                |c: &Cache| &c.entries,
848            ),
849            (
850                "cache_evictions_total",
851                "The count of cache evictions.",
852                "counter",
853                true,
854                |c: &Cache| &c.evictions,
855            ),
856            (
857                "cache_hits_total",
858                "The count of cache hits.",
859                "counter",
860                true,
861                |c: &Cache| &c.hits,
862            ),
863            (
864                "cache_misses_total",
865                "The count of cache misses. Deprecated, derive misses from cache hits/requests counters.",
866                "counter",
867                false,
868                |c: &Cache| std::slice::from_ref(&c.misses),
869            ),
870            (
871                "cache_prefetch_total",
872                "The number of times the cache has prefetched a cached item.",
873                "counter",
874                false,
875                |c: &Cache| std::slice::from_ref(&c.prefetches),
876            ),
877            (
878                "cache_requests_total",
879                "The count of cache requests.",
880                "counter",
881                false,
882                |c: &Cache| std::slice::from_ref(&c.requests),
883            ),
884            (
885                "cache_served_stale_total",
886                "The number of requests served from stale cache entries.",
887                "counter",
888                false,
889                |c: &Cache| std::slice::from_ref(&c.served_stale),
890            ),
891        ] {
892            header(out, name, help, kind);
893            let mut sorted = SortedSamples::default();
894            for ((server, zones, view), cache) in caches.iter() {
895                if typed {
896                    for (index, typ) in ["success", "denial"].iter().enumerate() {
897                        sorted.sample(
898                            name,
899                            &[
900                                ("server", server),
901                                ("type", typ),
902                                ("view", view),
903                                ("zones", zones),
904                            ],
905                            get(cache)[index].load(Ordering::Relaxed),
906                        );
907                    }
908                } else {
909                    sorted.sample(
910                        name,
911                        &[("server", server), ("view", view), ("zones", zones)],
912                        get(cache)[0].load(Ordering::Relaxed),
913                    );
914                }
915            }
916            sorted.write(out);
917        }
918    }
919
920    fn encode_proxies(&self, out: &mut String) {
921        let proxies = self.0.proxies.lock().unwrap();
922        for (name, help, hits) in [
923            (
924                "proxy_conn_cache_hits_total",
925                "Counter of connection cache hits per upstream and protocol.",
926                true,
927            ),
928            (
929                "proxy_conn_cache_misses_total",
930                "Counter of connection cache misses per upstream and protocol.",
931                false,
932            ),
933        ] {
934            let mut sorted = SortedSamples::default();
935            for proxy in proxies.values() {
936                for (proto, cache) in proxy.caches.lock().unwrap().iter() {
937                    let n = if hits { &cache.hits } else { &cache.misses }.load(Ordering::Relaxed);
938                    if n != 0 {
939                        sorted.sample(
940                            name,
941                            &[
942                                ("proto", proto),
943                                ("proxy_name", &proxy.proxy_name),
944                                ("to", &proxy.to),
945                            ],
946                            n,
947                        );
948                    }
949                }
950            }
951            if !sorted.0.is_empty() {
952                header(out, name, help, "counter");
953                sorted.write(out);
954            }
955        }
956        let mut sorted = SortedSamples::default();
957        for proxy in proxies.values() {
958            let n = proxy.healthcheck_failures.load(Ordering::Relaxed);
959            if n != 0 {
960                sorted.sample(
961                    "proxy_healthcheck_failures_total",
962                    &[("proxy_name", &proxy.proxy_name), ("to", &proxy.to)],
963                    n,
964                );
965            }
966        }
967        if !sorted.0.is_empty() {
968            header(
969                out,
970                "proxy_healthcheck_failures_total",
971                "Counter of the number of failed healthchecks.",
972                "counter",
973            );
974            sorted.write(out);
975        }
976        let mut sorted = SortedSamples::default();
977        for proxy in proxies.values() {
978            for (rcode, histogram) in proxy.durations.lock().unwrap().iter() {
979                if histogram.count() != 0 {
980                    let values = [
981                        ("proxy_name", proxy.proxy_name.as_str()),
982                        ("rcode", rcode.as_str()),
983                        ("to", proxy.to.as_str()),
984                    ];
985                    let mut text = String::new();
986                    encode_histogram(
987                        &mut text,
988                        "proxy_request_duration_seconds",
989                        &values,
990                        TIME_BUCKETS,
991                        histogram,
992                    );
993                    sorted.insert(&values, text);
994                }
995            }
996        }
997        if !sorted.0.is_empty() {
998            header(
999                out,
1000                "proxy_request_duration_seconds",
1001                "Histogram of the time each request took.",
1002                "histogram",
1003            );
1004            sorted.write(out);
1005        }
1006    }
1007}
1008
1009impl Default for Registry {
1010    fn default() -> Self {
1011        let registry = Self::new(
1012            env!("CARGO_PKG_VERSION"),
1013            env!("PHAROS_REVISION"),
1014            env!("PHAROS_RUSTC"),
1015        );
1016        registry.enable_process_metrics();
1017        registry
1018    }
1019}
1020
1021impl Block {
1022    fn labels(&self) -> [(&str, &str); 3] {
1023        [
1024            ("server", &self.server),
1025            ("view", &self.view),
1026            ("zone", &self.zone),
1027        ]
1028    }
1029}
1030
1031impl BlockMetrics {
1032    pub fn plugin(&self, name: &str) -> PluginMetrics {
1033        let mut plugins = self.0.plugins.lock().unwrap();
1034        let responses = Arc::clone(
1035            plugins
1036                .entry(name.into())
1037                .or_insert_with(|| Arc::new(counters(RESPONSE_CODES))),
1038        );
1039        PluginMetrics {
1040            block: Arc::clone(&self.0),
1041            responses,
1042        }
1043    }
1044
1045    pub fn plugin_enabled(&self, name: &str) {
1046        self.0.enabled.lock().unwrap().insert(name.into(), true);
1047    }
1048
1049    #[allow(clippy::too_many_arguments)]
1050    pub fn record(
1051        &self,
1052        qtype: u16,
1053        tcp: bool,
1054        ipv6: bool,
1055        dnssec_ok: bool,
1056        request_bytes: usize,
1057        response_bytes: usize,
1058        rcode: u16,
1059        duration: f64,
1060    ) {
1061        // The default series is resolved when the Server Block is created, not here.
1062        record(
1063            &self.0,
1064            &self.0.default_responses,
1065            qtype,
1066            tcp,
1067            ipv6,
1068            dnssec_ok,
1069            request_bytes,
1070            response_bytes,
1071            rcode,
1072            duration,
1073        );
1074    }
1075}
1076
1077impl PluginMetrics {
1078    #[allow(clippy::too_many_arguments)]
1079    pub fn record(
1080        &self,
1081        qtype: u16,
1082        tcp: bool,
1083        ipv6: bool,
1084        dnssec_ok: bool,
1085        request_bytes: usize,
1086        response_bytes: usize,
1087        rcode: u16,
1088        duration: f64,
1089    ) {
1090        record(
1091            &self.block,
1092            &self.responses,
1093            qtype,
1094            tcp,
1095            ipv6,
1096            dnssec_ok,
1097            request_bytes,
1098            response_bytes,
1099            rcode,
1100            duration,
1101        );
1102    }
1103}
1104
1105#[allow(clippy::too_many_arguments)]
1106fn record(
1107    block: &Block,
1108    responses: &[AtomicU64],
1109    qtype: u16,
1110    tcp: bool,
1111    ipv6: bool,
1112    dnssec_ok: bool,
1113    request_bytes: usize,
1114    response_bytes: usize,
1115    rcode: u16,
1116    duration: f64,
1117) {
1118    // RFC 6891 §6.1.3: the extended RCODE combines eight OPT bits with four header bits.
1119    assert!(
1120        usize::from(rcode) < RESPONSE_CODES,
1121        "DNS RCODE must fit in 12 bits"
1122    );
1123    let typ = TYPES
1124        .iter()
1125        .position(|(code, _)| *code == qtype)
1126        .unwrap_or(TYPES.len());
1127    let proto = usize::from(tcp);
1128    block.requests[(usize::from(ipv6) * 2 + proto) * (TYPES.len() + 1) + typ]
1129        .fetch_add(1, Ordering::Relaxed);
1130    if dnssec_ok {
1131        block.do_requests.fetch_add(1, Ordering::Relaxed);
1132    }
1133    block.duration.observe(TIME_BUCKETS, duration);
1134    block.request_size[proto].observe(SIZE_BUCKETS, request_bytes as f64);
1135    block.response_size[proto].observe(SIZE_BUCKETS, response_bytes as f64);
1136    responses[usize::from(rcode)].fetch_add(1, Ordering::Relaxed);
1137}
1138
1139// Prometheus text exposition §Comments, help text, and type information: emit HELP and TYPE.
1140fn header(out: &mut String, name: &str, help: &str, kind: &str) {
1141    writeln!(
1142        out,
1143        "# HELP pharos_{name} {help}\n# TYPE pharos_{name} {kind}"
1144    )
1145    .unwrap();
1146}
1147
1148// Prometheus text exposition §Line format: escape backslash, quote, and line feed in labels.
1149fn labels(out: &mut String, values: &[(&str, &str)]) {
1150    for (index, (name, value)) in values.iter().enumerate() {
1151        if index != 0 {
1152            out.push(',');
1153        }
1154        write!(out, "{name}=\"").unwrap();
1155        for c in value.chars() {
1156            match c {
1157                '\\' => out.push_str("\\\\"),
1158                '"' => out.push_str("\\\""),
1159                '\n' => out.push_str("\\n"),
1160                _ => out.push(c),
1161            }
1162        }
1163        out.push('"');
1164    }
1165}
1166
1167fn sample(out: &mut String, name: &str, values: &[(&str, &str)], n: u64) {
1168    write!(out, "pharos_{name}{{").unwrap();
1169    labels(out, values);
1170    writeln!(out, "}} {}", float(n as f64)).unwrap();
1171}
1172
1173#[derive(Default)]
1174struct SortedSamples(Vec<(Vec<String>, String)>);
1175
1176impl SortedSamples {
1177    fn insert(&mut self, values: &[(&str, &str)], text: String) {
1178        self.0.push((
1179            values.iter().map(|(_, value)| (*value).into()).collect(),
1180            text,
1181        ));
1182    }
1183
1184    fn sample(&mut self, name: &str, values: &[(&str, &str)], n: u64) {
1185        let mut text = String::new();
1186        sample(&mut text, name, values, n);
1187        self.insert(values, text);
1188    }
1189
1190    fn write(mut self, out: &mut String) {
1191        self.0.sort_unstable_by(|a, b| a.0.cmp(&b.0));
1192        for (_, text) in self.0 {
1193            out.push_str(&text);
1194        }
1195    }
1196}
1197
1198pub(crate) fn float(value: f64) -> String {
1199    // Prometheus text exposition §Line format: NaN, +Inf, and -Inf are valid sample values.
1200    if value.is_nan() {
1201        return "NaN".into();
1202    }
1203    if value == f64::INFINITY {
1204        return "+Inf".into();
1205    }
1206    if value == f64::NEG_INFINITY {
1207        return "-Inf".into();
1208    }
1209    if value != 0. && (value.abs() < 0.0001 || value.abs() >= 1_000_000.) {
1210        let text = format!("{value:e}");
1211        let (mantissa, exp) = text.split_once('e').unwrap();
1212        let exponent: i32 = exp.parse().unwrap();
1213        return format!(
1214            "{mantissa}e{}{abs:02}",
1215            if exponent < 0 { '-' } else { '+' },
1216            abs = exponent.abs()
1217        );
1218    }
1219    value.to_string()
1220}
1221
1222fn histogram_family<'a>(
1223    out: &mut String,
1224    blocks: &'a BTreeMap<Key, Arc<Block>>,
1225    name: &str,
1226    help: &str,
1227    bounds: &[f64],
1228    get: impl Fn(&'a Block) -> Vec<(Option<&'static str>, &'a Histogram)>,
1229) {
1230    if !blocks
1231        .values()
1232        .any(|b| get(b).iter().any(|(_, h)| h.count() != 0))
1233    {
1234        return;
1235    }
1236    header(out, name, help, "histogram");
1237    let mut sorted = SortedSamples::default();
1238    for b in blocks.values() {
1239        for (proto, histogram) in get(b) {
1240            if histogram.count() == 0 {
1241                continue;
1242            }
1243            let mut values = Vec::new();
1244            if let Some(proto) = proto {
1245                values.push(("proto", proto));
1246            }
1247            values.extend(b.labels());
1248            let mut text = String::new();
1249            encode_histogram(&mut text, name, &values, bounds, histogram);
1250            sorted.insert(&values, text);
1251        }
1252    }
1253    sorted.write(out);
1254}
1255
1256// Prometheus text exposition §Histograms and summaries: buckets are ordered and end with +Inf.
1257fn encode_histogram(
1258    out: &mut String,
1259    name: &str,
1260    values: &[(&str, &str)],
1261    bounds: &[f64],
1262    histogram: &Histogram,
1263) {
1264    let mut count = 0;
1265    for (i, bucket) in histogram.buckets.iter().enumerate() {
1266        count += bucket.load(Ordering::Relaxed);
1267        write!(out, "pharos_{name}_bucket{{").unwrap();
1268        labels(out, values);
1269        let bound = bounds
1270            .get(i)
1271            .map_or_else(|| "+Inf".into(), |value| float(*value));
1272        writeln!(out, ",le=\"{bound}\"}} {}", float(count as f64)).unwrap();
1273    }
1274    write!(out, "pharos_{name}_sum{{").unwrap();
1275    labels(out, values);
1276    writeln!(
1277        out,
1278        "}} {}",
1279        float(f64::from_bits(histogram.sum.load(Ordering::Relaxed)))
1280    )
1281    .unwrap();
1282    write!(out, "pharos_{name}_count{{").unwrap();
1283    labels(out, values);
1284    writeln!(out, "}} {}", float(count as f64)).unwrap();
1285}
1286
1287fn rcode(code: u16) -> String {
1288    match code {
1289        0 => "NOERROR",
1290        1 => "FORMERR",
1291        2 => "SERVFAIL",
1292        3 => "NXDOMAIN",
1293        4 => "NOTIMP",
1294        5 => "REFUSED",
1295        6 => "YXDOMAIN",
1296        7 => "YXRRSET",
1297        8 => "NXRRSET",
1298        9 => "NOTAUTH",
1299        10 => "NOTZONE",
1300        11 => "DSOTYPENI",
1301        16 => "BADSIG",
1302        17 => "BADKEY",
1303        18 => "BADTIME",
1304        19 => "BADMODE",
1305        20 => "BADNAME",
1306        21 => "BADALG",
1307        22 => "BADTRUNC",
1308        23 => "BADCOOKIE",
1309        _ => return format!("RCODE{code}"),
1310    }
1311    .into()
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316    #[test]
1317    fn kubernetes_metrics_export_coredns_labels_and_buckets() {
1318        let registry = super::Registry::new("test", "test", "test");
1319        let metrics = registry.kubernetes();
1320        let request = metrics.request("GET", "api:6443");
1321        request.observe(0.02, "200");
1322        request.rate_limiter(0.003);
1323        metrics.programming_duration("cluster_ip", 0.002);
1324        let mut out = String::new();
1325        registry.encode(&mut out);
1326        for line in [
1327            "pharos_kubernetes_rest_client_request_duration_seconds_count{host=\"api:6443\",verb=\"GET\"} 1",
1328            "pharos_kubernetes_rest_client_rate_limiter_duration_seconds_count{host=\"api:6443\",verb=\"GET\"} 1",
1329            "pharos_kubernetes_rest_client_requests_total{code=\"200\",host=\"api:6443\",method=\"GET\"} 1",
1330            "pharos_kubernetes_dns_programming_duration_seconds_bucket{service_kind=\"cluster_ip\",le=\"0.002\"} 1",
1331        ] {
1332            assert!(
1333                out.lines().any(|actual| actual == line),
1334                "missing {line}\n{out}"
1335            );
1336        }
1337    }
1338    #[test]
1339    fn proxy_series_share_labels_and_escape_values() {
1340        let registry = super::Registry::new("test", "test", "test");
1341        let upstream = registry.proxy("forward\"\n", "192.0.2.1:53");
1342        let same = registry.proxy("forward\"\n", "192.0.2.1:53");
1343        let other = registry.proxy("forward\"\n", "192.0.2.2:53");
1344        upstream.request_duration("SERVFAIL").observe(9.0);
1345        same.request_duration("SERVFAIL").observe(0.0005);
1346        other.request_duration("NOERROR").observe(0.0);
1347        upstream.connection_cache("tcp").hit();
1348        same.connection_cache("tcp").hit();
1349        other.connection_cache("udp").miss();
1350        same.healthcheck_failure();
1351
1352        let mut out = String::new();
1353        registry.encode(&mut out);
1354        for expected in [
1355            "pharos_proxy_request_duration_seconds_bucket{proxy_name=\"forward\\\"\\n\",rcode=\"SERVFAIL\",to=\"192.0.2.1:53\",le=\"0.00025\"} 0",
1356            "pharos_proxy_request_duration_seconds_bucket{proxy_name=\"forward\\\"\\n\",rcode=\"SERVFAIL\",to=\"192.0.2.1:53\",le=\"8.192\"} 1",
1357            "pharos_proxy_request_duration_seconds_bucket{proxy_name=\"forward\\\"\\n\",rcode=\"SERVFAIL\",to=\"192.0.2.1:53\",le=\"+Inf\"} 2",
1358            "pharos_proxy_request_duration_seconds_count{proxy_name=\"forward\\\"\\n\",rcode=\"NOERROR\",to=\"192.0.2.2:53\"} 1",
1359            "pharos_proxy_conn_cache_hits_total{proto=\"tcp\",proxy_name=\"forward\\\"\\n\",to=\"192.0.2.1:53\"} 2",
1360            "pharos_proxy_conn_cache_misses_total{proto=\"udp\",proxy_name=\"forward\\\"\\n\",to=\"192.0.2.2:53\"} 1",
1361            "pharos_proxy_healthcheck_failures_total{proxy_name=\"forward\\\"\\n\",to=\"192.0.2.1:53\"} 1",
1362        ] {
1363            assert!(
1364                out.lines().any(|line| line == expected),
1365                "missing {expected}\n{out}"
1366            );
1367        }
1368    }
1369
1370    #[test]
1371    fn unused_proxy_handles_do_not_emit_series() {
1372        let registry = super::Registry::new("test", "test", "test");
1373        let proxy = registry.proxy("forward", "192.0.2.1:53");
1374        let _ = proxy.request_duration("NOERROR");
1375        let _ = proxy.connection_cache("udp");
1376        let mut out = String::new();
1377        registry.encode(&mut out);
1378        assert!(!out.contains("pharos_proxy_"));
1379    }
1380
1381    #[test]
1382    fn forward_counters_are_shared_and_exported() {
1383        let registry = super::Registry::new("test", "test", "test");
1384        let counters = registry.forward();
1385        let same = registry.forward();
1386        counters.healthcheck_broken();
1387        same.concurrent_reject();
1388        let mut out = String::new();
1389        registry.encode(&mut out);
1390        assert!(out.contains("pharos_forward_healthcheck_broken_total 1\n"));
1391        assert!(out.contains("pharos_forward_max_concurrent_rejects_total 1\n"));
1392    }
1393
1394    #[test]
1395    fn proxy_metrics_export_upstream_series() {
1396        let registry = super::Registry::new("test", "test", "test");
1397        let upstream = registry.proxy("forward", "dns://192.0.2.1:53");
1398        let duration = upstream.request_duration("NOERROR");
1399        let cache = upstream.connection_cache("udp");
1400        duration.observe(0.00025);
1401        duration.observe(0.001);
1402        cache.hit();
1403        cache.hit();
1404        cache.miss();
1405        upstream.healthcheck_failure();
1406        let mut out = String::new();
1407        registry.encode(&mut out);
1408
1409        for expected in [
1410            "# HELP pharos_proxy_request_duration_seconds Histogram of the time each request took.",
1411            "# TYPE pharos_proxy_request_duration_seconds histogram",
1412            "# HELP pharos_proxy_healthcheck_failures_total Counter of the number of failed healthchecks.",
1413            "# TYPE pharos_proxy_healthcheck_failures_total counter",
1414            "# HELP pharos_proxy_conn_cache_hits_total Counter of connection cache hits per upstream and protocol.",
1415            "# TYPE pharos_proxy_conn_cache_hits_total counter",
1416            "# HELP pharos_proxy_conn_cache_misses_total Counter of connection cache misses per upstream and protocol.",
1417            "# TYPE pharos_proxy_conn_cache_misses_total counter",
1418            "pharos_proxy_request_duration_seconds_bucket{proxy_name=\"forward\",rcode=\"NOERROR\",to=\"dns://192.0.2.1:53\",le=\"0.00025\"} 1",
1419            "pharos_proxy_request_duration_seconds_bucket{proxy_name=\"forward\",rcode=\"NOERROR\",to=\"dns://192.0.2.1:53\",le=\"0.001\"} 2",
1420            "pharos_proxy_request_duration_seconds_bucket{proxy_name=\"forward\",rcode=\"NOERROR\",to=\"dns://192.0.2.1:53\",le=\"+Inf\"} 2",
1421            "pharos_proxy_request_duration_seconds_sum{proxy_name=\"forward\",rcode=\"NOERROR\",to=\"dns://192.0.2.1:53\"} 0.00125",
1422            "pharos_proxy_request_duration_seconds_count{proxy_name=\"forward\",rcode=\"NOERROR\",to=\"dns://192.0.2.1:53\"} 2",
1423            "pharos_proxy_healthcheck_failures_total{proxy_name=\"forward\",to=\"dns://192.0.2.1:53\"} 1",
1424            "pharos_proxy_conn_cache_hits_total{proto=\"udp\",proxy_name=\"forward\",to=\"dns://192.0.2.1:53\"} 2",
1425            "pharos_proxy_conn_cache_misses_total{proto=\"udp\",proxy_name=\"forward\",to=\"dns://192.0.2.1:53\"} 1",
1426        ] {
1427            assert!(
1428                out.lines().any(|line| line == expected),
1429                "missing {expected}\n{out}"
1430            );
1431        }
1432    }
1433
1434    #[test]
1435    fn go_float_formatting() {
1436        for (value, expected) in [
1437            (0.0001, "0.0001"),
1438            (0.00001, "1e-05"),
1439            (999999., "999999"),
1440            (1_000_000., "1e+06"),
1441            (-0.0, "-0"),
1442            (f64::INFINITY, "+Inf"),
1443            (f64::NEG_INFINITY, "-Inf"),
1444            (f64::NAN, "NaN"),
1445        ] {
1446            assert_eq!(super::float(value), expected);
1447        }
1448    }
1449}