Skip to main content

chorus_client/
metrics.rs

1//! Backend-neutral metrics recorder and WAL metric handles.
2
3use std::sync::atomic::{AtomicI64, Ordering};
4use std::sync::Arc;
5
6/// Handle for a monotonically increasing metric.
7pub trait CounterFn: Send + Sync {
8    /// Increase the counter by `value`.
9    fn increment(&self, value: u64);
10}
11
12/// Handle for an absolute point-in-time metric.
13pub trait GaugeFn: Send + Sync {
14    /// Set the gauge to `value`.
15    fn set(&self, value: i64);
16}
17
18/// Handle for an additive metric that may increase or decrease.
19pub trait UpDownCounterFn: Send + Sync {
20    /// Change the counter by `value`.
21    fn increment(&self, value: i64);
22}
23
24/// Handle for a sampled distribution.
25pub trait HistogramFn: Send + Sync {
26    /// Record one observation.
27    fn record(&self, value: f64);
28}
29
30/// Registers backend-owned metric handles used directly by Chorus.
31///
32/// Labels are fixed when a handle is registered. Implementations should return
33/// another handle for the same backend time series when a name and label set is
34/// registered more than once, allowing several WAL volumes to share a registry.
35///
36/// Most Chorus metrics currently register with an empty label set; the
37/// per-replica durable-lag gauge adds only `zone`. Applications that construct
38/// several volumes over one backend recorder should therefore pass each volume
39/// a small recorder adapter that injects a stable `wal` or `volume` label into
40/// every registration while preserving labels supplied here. Passing the same
41/// recorder directly makes different volumes aggregate indistinguishably.
42pub trait MetricsRecorder: Send + Sync {
43    /// Register a monotonically increasing counter.
44    fn register_counter(
45        &self,
46        name: &str,
47        description: &str,
48        labels: &[(&str, &str)],
49    ) -> Arc<dyn CounterFn>;
50
51    /// Register an absolute point-in-time gauge.
52    fn register_gauge(
53        &self,
54        name: &str,
55        description: &str,
56        labels: &[(&str, &str)],
57    ) -> Arc<dyn GaugeFn>;
58
59    /// Register an additive counter that may increase or decrease.
60    fn register_up_down_counter(
61        &self,
62        name: &str,
63        description: &str,
64        labels: &[(&str, &str)],
65    ) -> Arc<dyn UpDownCounterFn>;
66
67    /// Register a histogram with the requested bucket boundaries.
68    fn register_histogram(
69        &self,
70        name: &str,
71        description: &str,
72        labels: &[(&str, &str)],
73        boundaries: &[f64],
74    ) -> Arc<dyn HistogramFn>;
75}
76
77/// Recorder used when the application does not configure metrics.
78#[derive(Clone, Copy, Debug, Default)]
79pub struct NoopMetricsRecorder;
80
81#[derive(Debug)]
82struct NoopMetric;
83
84impl CounterFn for NoopMetric {
85    fn increment(&self, _value: u64) {}
86}
87
88impl GaugeFn for NoopMetric {
89    fn set(&self, _value: i64) {}
90}
91
92impl UpDownCounterFn for NoopMetric {
93    fn increment(&self, _value: i64) {}
94}
95
96impl HistogramFn for NoopMetric {
97    fn record(&self, _value: f64) {}
98}
99
100impl MetricsRecorder for NoopMetricsRecorder {
101    fn register_counter(
102        &self,
103        _name: &str,
104        _description: &str,
105        _labels: &[(&str, &str)],
106    ) -> Arc<dyn CounterFn> {
107        Arc::new(NoopMetric)
108    }
109
110    fn register_gauge(
111        &self,
112        _name: &str,
113        _description: &str,
114        _labels: &[(&str, &str)],
115    ) -> Arc<dyn GaugeFn> {
116        Arc::new(NoopMetric)
117    }
118
119    fn register_up_down_counter(
120        &self,
121        _name: &str,
122        _description: &str,
123        _labels: &[(&str, &str)],
124    ) -> Arc<dyn UpDownCounterFn> {
125        Arc::new(NoopMetric)
126    }
127
128    fn register_histogram(
129        &self,
130        _name: &str,
131        _description: &str,
132        _labels: &[(&str, &str)],
133        _boundaries: &[f64],
134    ) -> Arc<dyn HistogramFn> {
135        Arc::new(NoopMetric)
136    }
137}
138
139pub(crate) struct Counter(Arc<dyn CounterFn>);
140
141impl Counter {
142    fn register(recorder: &dyn MetricsRecorder, name: &str, description: &str) -> Self {
143        Self(recorder.register_counter(name, description, &[]))
144    }
145
146    pub(crate) fn increment(&self) {
147        self.add(1);
148    }
149
150    pub(crate) fn add(&self, value: u64) {
151        self.0.increment(value);
152    }
153}
154
155pub(crate) struct Gauge(Arc<dyn GaugeFn>);
156
157impl Gauge {
158    fn register(recorder: &dyn MetricsRecorder, name: &str, description: &str) -> Self {
159        Self(recorder.register_gauge(name, description, &[]))
160    }
161
162    fn register_with_labels(
163        recorder: &dyn MetricsRecorder,
164        name: &str,
165        description: &str,
166        labels: &[(&str, &str)],
167    ) -> Self {
168        Self(recorder.register_gauge(name, description, labels))
169    }
170
171    pub(crate) fn set(&self, value: i64) {
172        self.0.set(value);
173    }
174
175    pub(crate) fn set_u64(&self, value: u64) {
176        self.set(i64::try_from(value).unwrap_or(i64::MAX));
177    }
178
179    pub(crate) fn set_usize(&self, value: usize) {
180        self.set(i64::try_from(value).unwrap_or(i64::MAX));
181    }
182}
183
184/// Crate-private aggregation for gauges with several concurrent contributors.
185///
186/// Segment rotation briefly overlaps old and successor writers. Keeping the
187/// per-writer contributions here avoids exposing bookkeeping callbacks through
188/// the public [`MetricsRecorder`] API while presenting one operator gauge per
189/// zone.
190pub(crate) struct AggregateGauge {
191    total: AtomicI64,
192    gauge: Gauge,
193}
194
195impl AggregateGauge {
196    fn register(
197        recorder: &dyn MetricsRecorder,
198        name: &str,
199        description: &str,
200        labels: &[(&str, &str)],
201    ) -> Self {
202        Self {
203            total: AtomicI64::new(0),
204            gauge: Gauge::register_with_labels(recorder, name, description, labels),
205        }
206    }
207
208    pub(crate) fn add(&self, value: i64) {
209        let total = self.total.fetch_add(value, Ordering::Relaxed) + value;
210        self.gauge.set(total.max(0));
211        let current = self.total.load(Ordering::Relaxed);
212        if current != total {
213            self.gauge.set(current.max(0));
214        }
215    }
216}
217
218pub(crate) struct UpDownCounter(Arc<dyn UpDownCounterFn>);
219
220impl UpDownCounter {
221    fn register(recorder: &dyn MetricsRecorder, name: &str, description: &str) -> Self {
222        Self(recorder.register_up_down_counter(name, description, &[]))
223    }
224
225    pub(crate) fn add(&self, value: i64) {
226        self.0.increment(value);
227    }
228}
229
230/// Prometheus-style latency buckets in seconds, wide enough to span a fast
231/// zonal append acknowledgment and a slow regional seal alike.
232const LATENCY_SECONDS_BOUNDARIES: &[f64] = &[
233    0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0,
234];
235
236pub(crate) struct Histogram(Arc<dyn HistogramFn>);
237
238impl Histogram {
239    fn register(recorder: &dyn MetricsRecorder, name: &str, description: &str) -> Self {
240        Self(recorder.register_histogram(name, description, &[], LATENCY_SECONDS_BOUNDARIES))
241    }
242
243    pub(crate) fn record_duration(&self, duration: std::time::Duration) {
244        self.0.record(duration.as_secs_f64());
245    }
246}
247
248pub(crate) struct HighWaterGauge {
249    high_water: AtomicI64,
250    gauge: Gauge,
251}
252
253impl HighWaterGauge {
254    fn register(recorder: &dyn MetricsRecorder, name: &str, description: &str) -> Self {
255        Self {
256            high_water: AtomicI64::new(0),
257            gauge: Gauge::register(recorder, name, description),
258        }
259    }
260
261    pub(crate) fn update_max(&self, value: u64) {
262        let value = i64::try_from(value).unwrap_or(i64::MAX);
263        if self.high_water.fetch_max(value, Ordering::Relaxed) < value {
264            self.gauge.set(value);
265            let current = self.high_water.load(Ordering::Relaxed);
266            if current > value {
267                self.gauge.set(current);
268            }
269        }
270    }
271}
272
273/// Handles for all metrics emitted by one WAL volume.
274pub(crate) struct Metrics {
275    pub(crate) append_records: Counter,
276    pub(crate) append_bytes: Counter,
277    pub(crate) append_failures: Counter,
278    pub(crate) committed_records: Counter,
279    pub(crate) committed_bytes: Counter,
280    pub(crate) batches_sent: Counter,
281    pub(crate) lane_retries: Counter,
282    pub(crate) lane_timeouts: Counter,
283    pub(crate) rotations_completed: Counter,
284    pub(crate) spare_provisioning_attempts: Counter,
285    pub(crate) spare_provisioning_failures: Counter,
286    pub(crate) segments_sealed: Counter,
287    pub(crate) seal_enforcement_retries: Counter,
288    pub(crate) manifest_cas_attempts: Counter,
289    pub(crate) manifest_cas_conflicts: Counter,
290    pub(crate) repair_passes: Counter,
291    pub(crate) repair_objects_repaired: Counter,
292    pub(crate) repair_transient_skips: Counter,
293    pub(crate) repair_failures: Counter,
294    pub(crate) recoveries_run: Counter,
295    pub(crate) recovery_segments_adopted: Counter,
296    pub(crate) orphan_objects_deleted: Counter,
297    pub(crate) orphan_sweeps_deferred: Counter,
298    pub(crate) truncation_cycles: Counter,
299    pub(crate) operation_failures: Counter,
300    pub(crate) max_inflight_records: HighWaterGauge,
301    pub(crate) max_inflight_bytes: HighWaterGauge,
302    pub(crate) pipeline_refills: Counter,
303    pub(crate) lane_capacity_drops: Counter,
304    pub(crate) wal_record_bytes: Counter,
305    pub(crate) replica_bytes_attempted: Counter,
306    pub(crate) open_segments: UpDownCounter,
307    pub(crate) committed_records_watermark: Gauge,
308    pub(crate) queue_depth: Gauge,
309    zone_durable_lag: Vec<AggregateGauge>,
310    pub(crate) rotation_state: Gauge,
311    maintenance_queue_depth: AggregateGauge,
312    pub(crate) manifest_directory_bytes: Gauge,
313    pub(crate) append_commit_latency: Histogram,
314    pub(crate) manifest_cas_latency: Histogram,
315    pub(crate) seal_duration: Histogram,
316}
317
318impl Metrics {
319    pub(crate) fn new(recorder: &dyn MetricsRecorder, replica_count: usize) -> Self {
320        macro_rules! counter {
321            ($name:literal, $description:literal) => {
322                Counter::register(recorder, $name, $description)
323            };
324        }
325        macro_rules! gauge {
326            ($name:literal, $description:literal) => {
327                Gauge::register(recorder, $name, $description)
328            };
329        }
330        macro_rules! high_water {
331            ($name:literal, $description:literal) => {
332                HighWaterGauge::register(recorder, $name, $description)
333            };
334        }
335        macro_rules! histogram {
336            ($name:literal, $description:literal) => {
337                Histogram::register(recorder, $name, $description)
338            };
339        }
340
341        let zone_durable_lag = (0..replica_count)
342            .map(|zone| {
343                let zone = zone.to_string();
344                AggregateGauge::register(
345                    recorder,
346                    "chorus.wal.replica.durable_lag_bytes",
347                    "Encoded admitted bytes not yet durable in this replica zone",
348                    &[("zone", zone.as_str())],
349                )
350            })
351            .collect();
352
353        Self {
354            append_records: counter!(
355                "chorus.wal.append.records",
356                "Application records admitted for append"
357            ),
358            append_bytes: counter!(
359                "chorus.wal.append.bytes",
360                "Application payload bytes admitted for append"
361            ),
362            append_failures: counter!(
363                "chorus.wal.append.failures",
364                "Admitted appends that completed with an error"
365            ),
366            committed_records: counter!(
367                "chorus.wal.append.committed_records",
368                "Records committed in contiguous sequence order"
369            ),
370            committed_bytes: counter!(
371                "chorus.wal.append.committed_bytes",
372                "Application payload bytes committed in contiguous sequence order"
373            ),
374            batches_sent: counter!(
375                "chorus.wal.batch.sent",
376                "Logical record batches submitted to replication"
377            ),
378            lane_retries: counter!(
379                "chorus.wal.lane.retries",
380                "Replica lane recovery or resend attempts"
381            ),
382            lane_timeouts: counter!(
383                "chorus.wal.lane.timeouts",
384                "Replica lanes shed after making no durable progress before their timeout"
385            ),
386            rotations_completed: counter!(
387                "chorus.wal.rotation.completed",
388                "Active-segment rotations completed"
389            ),
390            spare_provisioning_attempts: counter!(
391                "chorus.wal.rotation.spare_provisioning_attempts",
392                "Background spare provisioning attempts"
393            ),
394            spare_provisioning_failures: counter!(
395                "chorus.wal.rotation.spare_provisioning_failures",
396                "Background spare provisioning failures"
397            ),
398            segments_sealed: counter!(
399                "chorus.wal.seal.segments",
400                "Segments whose committed seal was enforced"
401            ),
402            seal_enforcement_retries: counter!(
403                "chorus.wal.seal.enforcement_retries",
404                "Committed-seal enforcement retry attempts started by maintenance"
405            ),
406            manifest_cas_attempts: counter!(
407                "chorus.wal.manifest.cas_attempts",
408                "Manifest compare-and-swap requests sent to storage"
409            ),
410            manifest_cas_conflicts: counter!(
411                "chorus.wal.manifest.cas_conflicts",
412                "Manifest compare-and-swap precondition conflicts"
413            ),
414            repair_passes: counter!(
415                "chorus.wal.repair.passes",
416                "Sealed-segment repair passes completed or skipped after an error"
417            ),
418            repair_objects_repaired: counter!(
419                "chorus.wal.repair.objects_repaired",
420                "Missing or divergent sealed objects repaired"
421            ),
422            repair_transient_skips: counter!(
423                "chorus.wal.repair.transient_skips",
424                "Repair targets skipped after transient failures"
425            ),
426            repair_failures: counter!(
427                "chorus.wal.repair.failures",
428                "Repair passes aborted by an error"
429            ),
430            recoveries_run: counter!("chorus.wal.recovery.runs", "Recovery attempts started"),
431            recovery_segments_adopted: counter!(
432                "chorus.wal.recovery.segments_adopted",
433                "Sealed segments adopted by recovery"
434            ),
435            orphan_objects_deleted: counter!(
436                "chorus.wal.orphan.objects_deleted",
437                "Dead-incarnation segment-object copies deleted by maintenance"
438            ),
439            orphan_sweeps_deferred: counter!(
440                "chorus.wal.orphan.sweeps_deferred",
441                "Dead-incarnation sweeps deferred after transient or terminal storage failures"
442            ),
443            truncation_cycles: counter!(
444                "chorus.wal.truncation.cycles",
445                "Application-triggered truncation cycles completed"
446            ),
447            operation_failures: counter!(
448                "chorus.wal.operation.failures",
449                "Engine and maintenance operations completed with errors"
450            ),
451            max_inflight_records: high_water!(
452                "chorus.wal.pipeline.max_inflight_records",
453                "High-water mark of dispatched uncommitted records"
454            ),
455            max_inflight_bytes: high_water!(
456                "chorus.wal.pipeline.max_inflight_bytes",
457                "High-water mark of admitted unresolved encoded bytes"
458            ),
459            pipeline_refills: counter!(
460                "chorus.wal.pipeline.refills",
461                "Pipeline refills before all prior records completed"
462            ),
463            lane_capacity_drops: counter!(
464                "chorus.wal.lane.capacity_drops",
465                "Replica lanes dropped after exceeding their retained-byte budget"
466            ),
467            wal_record_bytes: counter!(
468                "chorus.wal.append.encoded_bytes",
469                "Encoded durable record bytes generated before replication"
470            ),
471            replica_bytes_attempted: counter!(
472                "chorus.wal.replica.bytes_attempted",
473                "Encoded bytes handed to replica transports including retries"
474            ),
475            open_segments: UpDownCounter::register(
476                recorder,
477                "chorus.wal.catalog.open_segments",
478                "Active appendable segments owned by this client",
479            ),
480            committed_records_watermark: gauge!(
481                "chorus.wal.append.committed_watermark",
482                "Exclusive committed record boundary"
483            ),
484            queue_depth: gauge!(
485                "chorus.wal.pipeline.queue_depth",
486                "Appends waiting across the admission channel and engine queue"
487            ),
488            zone_durable_lag,
489            rotation_state: gauge!(
490                "chorus.wal.rotation.state",
491                "Rotation gate state: 0 idle, 1 due, 2 draining, 3 sealing, 4 disabled"
492            ),
493            maintenance_queue_depth: AggregateGauge::register(
494                recorder,
495                "chorus.wal.maintenance.queue_depth",
496                "Maintenance requests waiting for execution",
497                &[],
498            ),
499            manifest_directory_bytes: gauge!(
500                "chorus.wal.manifest.directory_bytes",
501                "Encoded bytes used by the sealed segment directory"
502            ),
503            append_commit_latency: histogram!(
504                "chorus.wal.append.commit_latency_seconds",
505                "Seconds from append admission to contiguous quorum commit"
506            ),
507            manifest_cas_latency: histogram!(
508                "chorus.wal.manifest.cas_latency_seconds",
509                "Latency of one manifest compare-and-swap request"
510            ),
511            seal_duration: histogram!(
512                "chorus.wal.seal.duration_seconds",
513                "Seconds to finalize a swapped-out segment"
514            ),
515        }
516    }
517
518    pub(crate) fn adjust_zone_durable_lag(&self, zone: usize, delta: i64) {
519        if let Some(gauge) = self.zone_durable_lag.get(zone) {
520            gauge.add(delta);
521        }
522    }
523
524    pub(crate) fn adjust_maintenance_queue_depth(&self, delta: i64) {
525        self.maintenance_queue_depth.add(delta);
526    }
527}
528
529#[cfg(test)]
530pub(crate) mod test_support {
531    use std::collections::HashMap;
532    use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
533    use std::sync::{Arc, Mutex};
534
535    use super::{CounterFn, GaugeFn, HistogramFn, MetricsRecorder, UpDownCounterFn};
536
537    #[derive(Default)]
538    pub(crate) struct TestMetricsRecorder {
539        counters: Mutex<HashMap<String, Arc<AtomicU64>>>,
540        gauges: Mutex<HashMap<String, Arc<AtomicI64>>>,
541        up_down_counters: Mutex<HashMap<String, Arc<AtomicI64>>>,
542        histograms: Mutex<HashMap<String, Arc<Mutex<Vec<f64>>>>>,
543    }
544
545    impl TestMetricsRecorder {
546        pub(crate) fn counter(&self, name: &str) -> u64 {
547            self.counters.lock().unwrap()[name].load(Ordering::Relaxed)
548        }
549
550        pub(crate) fn gauge(&self, name: &str) -> i64 {
551            self.gauges.lock().unwrap()[name].load(Ordering::Relaxed)
552        }
553
554        pub(crate) fn labeled_gauge(&self, name: &str, labels: &[(&str, &str)]) -> i64 {
555            self.gauges.lock().unwrap()[&metric_key(name, labels)].load(Ordering::Relaxed)
556        }
557
558        pub(crate) fn up_down_counter(&self, name: &str) -> i64 {
559            self.up_down_counters.lock().unwrap()[name].load(Ordering::Relaxed)
560        }
561
562        pub(crate) fn histogram_samples(&self, name: &str) -> usize {
563            self.histograms.lock().unwrap()[name].lock().unwrap().len()
564        }
565    }
566
567    struct TestCounter(Arc<AtomicU64>);
568
569    impl CounterFn for TestCounter {
570        fn increment(&self, value: u64) {
571            self.0.fetch_add(value, Ordering::Relaxed);
572        }
573    }
574
575    struct TestGauge(Arc<AtomicI64>);
576
577    impl GaugeFn for TestGauge {
578        fn set(&self, value: i64) {
579            self.0.store(value, Ordering::Relaxed);
580        }
581    }
582
583    struct TestUpDownCounter(Arc<AtomicI64>);
584
585    impl UpDownCounterFn for TestUpDownCounter {
586        fn increment(&self, value: i64) {
587            self.0.fetch_add(value, Ordering::Relaxed);
588        }
589    }
590
591    struct TestHistogram(Arc<Mutex<Vec<f64>>>);
592
593    impl HistogramFn for TestHistogram {
594        fn record(&self, value: f64) {
595            self.0.lock().unwrap().push(value);
596        }
597    }
598
599    impl MetricsRecorder for TestMetricsRecorder {
600        fn register_counter(
601            &self,
602            name: &str,
603            _description: &str,
604            _labels: &[(&str, &str)],
605        ) -> Arc<dyn CounterFn> {
606            let metric = self
607                .counters
608                .lock()
609                .unwrap()
610                .entry(name.to_string())
611                .or_default()
612                .clone();
613            Arc::new(TestCounter(metric))
614        }
615
616        fn register_gauge(
617            &self,
618            name: &str,
619            _description: &str,
620            labels: &[(&str, &str)],
621        ) -> Arc<dyn GaugeFn> {
622            let key = metric_key(name, labels);
623            let metric = self.gauges.lock().unwrap().entry(key).or_default().clone();
624            Arc::new(TestGauge(metric))
625        }
626
627        fn register_up_down_counter(
628            &self,
629            name: &str,
630            _description: &str,
631            _labels: &[(&str, &str)],
632        ) -> Arc<dyn UpDownCounterFn> {
633            let metric = self
634                .up_down_counters
635                .lock()
636                .unwrap()
637                .entry(name.to_string())
638                .or_default()
639                .clone();
640            Arc::new(TestUpDownCounter(metric))
641        }
642
643        fn register_histogram(
644            &self,
645            name: &str,
646            _description: &str,
647            _labels: &[(&str, &str)],
648            _boundaries: &[f64],
649        ) -> Arc<dyn HistogramFn> {
650            let metric = self
651                .histograms
652                .lock()
653                .unwrap()
654                .entry(name.to_string())
655                .or_default()
656                .clone();
657            Arc::new(TestHistogram(metric))
658        }
659    }
660
661    fn metric_key(name: &str, labels: &[(&str, &str)]) -> String {
662        labels
663            .iter()
664            .fold(name.to_string(), |mut key, (name, value)| {
665                key.push('{');
666                key.push_str(name);
667                key.push('=');
668                key.push_str(value);
669                key.push('}');
670                key
671            })
672    }
673}