Skip to main content

tsoracle_server/
reporter.rs

1//
2//  ░▀█▀░█▀▀░█▀█░█▀▄░█▀█░█▀▀░█░░░█▀▀
3//  ░░█░░▀▀█░█░█░█▀▄░█▀█░█░░░█░░░█▀▀
4//  ░░▀░░▀▀▀░▀▀▀░▀░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀
5//
6//  tsoracle — Distributed Timestamp Oracle
7//  https://www.tsoracle.rs
8//
9//  Copyright (c) 2026 Prisma Risk
10//
11//  Licensed under the Apache License, Version 2.0 (the "License");
12//  you may not use this file except in compliance with the License.
13//  You may obtain a copy of the License at
14//
15//      https://www.apache.org/licenses/LICENSE-2.0
16//
17//  Unless required by applicable law or agreed to in writing, software
18//  distributed under the License is distributed on an "AS IS" BASIS,
19//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20//  See the License for the specific language governing permissions and
21//  limitations under the License.
22//
23
24//! Typed metric facade for `tsoracle-server`.
25//!
26//! `Reporter` is the single place metric-name string literals live in this
27//! crate. Each typed counter/histogram field forwards an increment/record to
28//! both the global `metrics::` recorder (Prometheus path, unchanged) and a
29//! local `Arc<AtomicU64>` that the heartbeat task can read without depending
30//! on any installed recorder.
31
32use std::sync::Arc;
33use std::sync::atomic::{AtomicU64, Ordering};
34use std::time::{Instant, SystemTime, UNIX_EPOCH};
35
36use tsoracle_core::IgnoreReason;
37
38pub struct ReporterCounter {
39    // Read only by the `metrics`-gated recorder forward in `increment`; with the
40    // `metrics` feature off the local atomic is the counter's sole live state.
41    #[cfg_attr(not(feature = "metrics"), allow(dead_code))]
42    name: &'static str,
43    local: Arc<AtomicU64>,
44}
45
46impl ReporterCounter {
47    pub(crate) fn new(name: &'static str) -> Self {
48        Self {
49            name,
50            local: Arc::new(AtomicU64::new(0)),
51        }
52    }
53
54    pub(crate) fn increment(&self, n: u64) {
55        #[cfg(feature = "metrics")]
56        metrics::counter!(self.name).increment(n);
57        self.local.fetch_add(n, Ordering::Relaxed);
58    }
59
60    // Read only by the heartbeat task, which is compiled solely under `tracing`.
61    #[cfg_attr(not(feature = "tracing"), allow(dead_code))]
62    pub(crate) fn snapshot(&self) -> u64 {
63        self.local.load(Ordering::Relaxed)
64    }
65}
66
67pub struct ReporterHistogram {
68    // Read only by the `metrics`-gated recorder forward in `record`; with the
69    // `metrics` feature off the histogram carries no local state.
70    #[cfg_attr(not(feature = "metrics"), allow(dead_code))]
71    name: &'static str,
72}
73
74impl ReporterHistogram {
75    pub(crate) fn new(name: &'static str) -> Self {
76        Self { name }
77    }
78
79    pub(crate) fn record(&self, _v: f64) {
80        #[cfg(feature = "metrics")]
81        metrics::histogram!(self.name).record(_v);
82    }
83}
84
85pub struct ReporterTimestamp {
86    local: Arc<AtomicU64>,
87}
88
89impl ReporterTimestamp {
90    pub(crate) fn new() -> Self {
91        Self {
92            local: Arc::new(AtomicU64::new(0)),
93        }
94    }
95
96    /// Record `SystemTime::now()` as unix-ms. Idempotent under concurrent
97    /// callers — the last writer wins under `Relaxed`, which matches the
98    /// "approximate last transition time" semantics the heartbeat needs.
99    pub(crate) fn touch_now(&self) {
100        self.local.store(now_unix_ms(), Ordering::Relaxed);
101    }
102
103    /// `None` if never touched, else the most recent unix-ms.
104    // Read only by the heartbeat task, which is compiled solely under `tracing`.
105    #[cfg_attr(not(feature = "tracing"), allow(dead_code))]
106    pub(crate) fn snapshot(&self) -> Option<u64> {
107        let v = self.local.load(Ordering::Relaxed);
108        (v != 0).then_some(v)
109    }
110}
111
112pub struct IgnoredCommitsByReason {
113    pub not_leader: ReporterCounter,
114    pub epoch_mismatch: ReporterCounter,
115    pub not_advanced: ReporterCounter,
116}
117
118impl IgnoredCommitsByReason {
119    fn new() -> Self {
120        Self {
121            not_leader: ReporterCounter::new("tsoracle.window.extensions.ignored.not_leader.total"),
122            epoch_mismatch: ReporterCounter::new(
123                "tsoracle.window.extensions.ignored.epoch_mismatch.total",
124            ),
125            not_advanced: ReporterCounter::new(
126                "tsoracle.window.extensions.ignored.not_advanced.total",
127            ),
128        }
129    }
130
131    pub(crate) fn for_reason(&self, reason: IgnoreReason) -> &ReporterCounter {
132        match reason {
133            IgnoreReason::NotLeader => &self.not_leader,
134            IgnoreReason::EpochMismatch { .. } => &self.epoch_mismatch,
135            IgnoreReason::NotAdvanced { .. } => &self.not_advanced,
136        }
137    }
138}
139
140pub struct Reporter {
141    // get_ts hot path
142    pub get_ts_requests: ReporterCounter,
143    pub get_ts_success: ReporterCounter,
144    pub timestamps_issued: ReporterCounter,
145
146    // get_seq hot path
147    pub get_seq_requests: ReporterCounter,
148    pub get_seq_success: ReporterCounter,
149    pub seq_values_issued: ReporterCounter,
150    pub seq_cardinality_rejected: ReporterCounter,
151
152    // get_seq_batch hot path. Batch keeps its OWN values/cardinality counters
153    // (rather than sharing the single-key ones) so the `tsoracle.get_seq.*`
154    // single-key series stays free of batch traffic and each RPC is separately
155    // observable.
156    pub get_seq_batch_requests: ReporterCounter,
157    pub get_seq_batch_success: ReporterCounter,
158    pub seq_batch_keys: ReporterHistogram,
159    pub seq_batch_values_issued: ReporterCounter,
160    pub seq_batch_cardinality_rejected: ReporterCounter,
161
162    // lease control-plane
163    pub lease_acquire_requests: ReporterCounter,
164    pub lease_acquire_success: ReporterCounter,
165    pub lease_renew_requests: ReporterCounter,
166    pub lease_renew_success: ReporterCounter,
167    pub lease_release_requests: ReporterCounter,
168
169    // leader / fence
170    pub not_leader: ReporterCounter,
171    pub leader_transitions: ReporterCounter,
172    pub fence_transient_retries: ReporterCounter,
173    pub fence_latency: ReporterHistogram,
174
175    // window
176    pub window_extensions: ReporterCounter,
177    pub window_extension_latency: ReporterHistogram,
178    pub ignored_commits: IgnoredCommitsByReason,
179
180    // lifecycle
181    pub shutdown_watch_aborted: ReporterCounter,
182    pub heartbeat_task_panicked: ReporterCounter,
183    pub last_leader_transition: ReporterTimestamp,
184    pub started_at: Instant,
185}
186
187impl Reporter {
188    pub fn new() -> Self {
189        Self {
190            get_ts_requests: ReporterCounter::new("tsoracle.get_ts.requests.total"),
191            get_ts_success: ReporterCounter::new("tsoracle.get_ts.success.total"),
192            timestamps_issued: ReporterCounter::new("tsoracle.get_ts.timestamps_issued"),
193            get_seq_requests: ReporterCounter::new("tsoracle.get_seq.requests.total"),
194            get_seq_success: ReporterCounter::new("tsoracle.get_seq.success.total"),
195            seq_values_issued: ReporterCounter::new("tsoracle.get_seq.values_issued"),
196            seq_cardinality_rejected: ReporterCounter::new(
197                "tsoracle.get_seq.cardinality_rejected.total",
198            ),
199            get_seq_batch_requests: ReporterCounter::new("tsoracle.get_seq.batch.requests.total"),
200            get_seq_batch_success: ReporterCounter::new("tsoracle.get_seq.batch.success.total"),
201            seq_batch_keys: ReporterHistogram::new("tsoracle.get_seq.batch.keys"),
202            seq_batch_values_issued: ReporterCounter::new("tsoracle.get_seq.batch.values_issued"),
203            seq_batch_cardinality_rejected: ReporterCounter::new(
204                "tsoracle.get_seq.batch.cardinality_rejected.total",
205            ),
206            lease_acquire_requests: ReporterCounter::new("tsoracle.lease.acquire.requests.total"),
207            lease_acquire_success: ReporterCounter::new("tsoracle.lease.acquire.success.total"),
208            lease_renew_requests: ReporterCounter::new("tsoracle.lease.renew.requests.total"),
209            lease_renew_success: ReporterCounter::new("tsoracle.lease.renew.success.total"),
210            lease_release_requests: ReporterCounter::new("tsoracle.lease.release.requests.total"),
211            not_leader: ReporterCounter::new("tsoracle.not_leader.total"),
212            leader_transitions: ReporterCounter::new("tsoracle.leader_transition.total"),
213            fence_transient_retries: ReporterCounter::new(
214                "tsoracle.leader_transition.fence_transient_retries.total",
215            ),
216            fence_latency: ReporterHistogram::new("tsoracle.leader_transition.fence_latency"),
217            window_extensions: ReporterCounter::new("tsoracle.window.extensions.total"),
218            window_extension_latency: ReporterHistogram::new("tsoracle.window.extension_latency"),
219            ignored_commits: IgnoredCommitsByReason::new(),
220            shutdown_watch_aborted: ReporterCounter::new("tsoracle.shutdown.watch_aborted.total"),
221            heartbeat_task_panicked: ReporterCounter::new("tsoracle.heartbeat.task_panicked.total"),
222            last_leader_transition: ReporterTimestamp::new(),
223            started_at: Instant::now(),
224        }
225    }
226
227    /// Convenience constructor for unit tests. Same as `Reporter::new()`; the
228    /// dedicated name documents intent at call sites (tests that just need a
229    /// Reporter to satisfy a struct field).
230    #[cfg(any(test, feature = "test-support"))]
231    pub fn for_tests() -> Self {
232        Self::new()
233    }
234}
235
236impl Default for Reporter {
237    fn default() -> Self {
238        Self::new()
239    }
240}
241
242pub(crate) fn now_unix_ms() -> u64 {
243    SystemTime::now()
244        .duration_since(UNIX_EPOCH)
245        .map(|d| d.as_millis() as u64)
246        .unwrap_or(0)
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn counter_increment_bumps_local_snapshot() {
255        let c = ReporterCounter::new("tsoracle.test.local");
256        assert_eq!(c.snapshot(), 0);
257        c.increment(1);
258        c.increment(4);
259        assert_eq!(c.snapshot(), 5);
260    }
261
262    #[test]
263    #[cfg(feature = "metrics")]
264    fn counter_increment_forwards_to_metrics_recorder() {
265        use metrics::Key;
266        use metrics_util::CompositeKey;
267        use metrics_util::MetricKind;
268        use metrics_util::debugging::{DebuggingRecorder, Snapshotter};
269
270        let recorder = DebuggingRecorder::new();
271        let snapshotter: Snapshotter = recorder.snapshotter();
272
273        metrics::with_local_recorder(&recorder, || {
274            let c = ReporterCounter::new("tsoracle.test.forwarded");
275            c.increment(7);
276        });
277
278        let snap = snapshotter.snapshot().into_vec();
279        let key = CompositeKey::new(
280            MetricKind::Counter,
281            Key::from_name("tsoracle.test.forwarded"),
282        );
283        let entry = snap
284            .iter()
285            .find(|(k, ..)| *k == key)
286            .expect("counter not recorded by DebuggingRecorder");
287        let (_, _, _, value) = entry;
288        assert_eq!(
289            format!("{value:?}"),
290            "Counter(7)",
291            "expected the recorder to observe Counter(7), got {value:?}"
292        );
293    }
294
295    #[test]
296    #[cfg(feature = "metrics")]
297    fn histogram_record_forwards_to_metrics_recorder() {
298        use metrics::Key;
299        use metrics_util::CompositeKey;
300        use metrics_util::MetricKind;
301        use metrics_util::debugging::DebuggingRecorder;
302
303        let recorder = DebuggingRecorder::new();
304        let snapshotter = recorder.snapshotter();
305
306        metrics::with_local_recorder(&recorder, || {
307            let h = ReporterHistogram::new("tsoracle.test.hist");
308            h.record(1.0);
309            h.record(2.0);
310            h.record(3.0);
311        });
312
313        let snap = snapshotter.snapshot().into_vec();
314        let key = CompositeKey::new(MetricKind::Histogram, Key::from_name("tsoracle.test.hist"));
315        assert!(
316            snap.iter().any(|(k, ..)| *k == key),
317            "histogram key not recorded"
318        );
319    }
320
321    #[test]
322    fn timestamp_zero_is_none() {
323        let t = ReporterTimestamp::new();
324        assert_eq!(t.snapshot(), None);
325    }
326
327    #[test]
328    fn timestamp_touch_records_now() {
329        let before = now_unix_ms();
330        let t = ReporterTimestamp::new();
331        t.touch_now();
332        let after = now_unix_ms();
333        let observed = t.snapshot().expect("touch_now should produce Some");
334        assert!(
335            observed >= before && observed <= after,
336            "timestamp {observed} not within [{before}, {after}]"
337        );
338    }
339
340    #[test]
341    #[cfg(not(feature = "metrics"))]
342    fn counter_works_without_metrics_feature() {
343        // With the `metrics` feature off, the increment body skips the
344        // recorder forward but the local atomic still bumps — the heartbeat
345        // path stays functional in default tsoracle-bin builds (which do not
346        // enable the `metrics` feature on tsoracle-server).
347        let c = ReporterCounter::new("tsoracle.test.no_metrics");
348        c.increment(3);
349        assert_eq!(c.snapshot(), 3);
350    }
351
352    #[test]
353    #[cfg(feature = "metrics")]
354    fn reporter_new_resolves_distinct_metric_names() {
355        use metrics::Key;
356        use metrics_util::CompositeKey;
357        use metrics_util::MetricKind;
358        use metrics_util::debugging::DebuggingRecorder;
359        use tsoracle_core::{Epoch, IgnoreReason};
360
361        let recorder = DebuggingRecorder::new();
362        let snapshotter = recorder.snapshotter();
363
364        metrics::with_local_recorder(&recorder, || {
365            let r = Reporter::new();
366            r.ignored_commits
367                .for_reason(IgnoreReason::NotLeader)
368                .increment(1);
369            r.ignored_commits
370                .for_reason(IgnoreReason::EpochMismatch {
371                    expected: Epoch(1),
372                    current: Epoch(2),
373                })
374                .increment(1);
375            r.ignored_commits
376                .for_reason(IgnoreReason::NotAdvanced {
377                    persisted: 1,
378                    committed: 2,
379                })
380                .increment(1);
381        });
382
383        let snap = snapshotter.snapshot().into_vec();
384        for name in [
385            "tsoracle.window.extensions.ignored.not_leader.total",
386            "tsoracle.window.extensions.ignored.epoch_mismatch.total",
387            "tsoracle.window.extensions.ignored.not_advanced.total",
388        ] {
389            let key = CompositeKey::new(MetricKind::Counter, Key::from_name(name));
390            assert!(
391                snap.iter().any(|(k, ..)| *k == key),
392                "expected metric {name} to be recorded distinctly"
393            );
394        }
395    }
396}