Skip to main content

pamoja_telemetry/
reporter.rs

1//! Recording events, filtering them for the link, and summarizing the counts.
2
3use crate::event::{Event, Level};
4
5// The number of [`Level`] variants, the width of the per-level counters.
6const LEVEL_COUNT: usize = 5;
7
8/// How costly the current link is, which sets how selective telemetry should be.
9///
10/// The cost maps to the level [`threshold`](LinkCost::threshold) a
11/// [`Reporter`] should use: a free link ships everything, while an expensive or
12/// absent link ships only what is worth its bytes.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum LinkCost {
15    /// A free or local link: ship all detail.
16    Free,
17    /// A metered link: skip routine detail.
18    Metered,
19    /// An expensive link, such as satellite: ship only warnings and errors.
20    Expensive,
21    /// No link: hold back everything but errors worth buffering.
22    Offline,
23}
24
25impl LinkCost {
26    /// Returns the level threshold this link cost calls for.
27    ///
28    /// # Returns
29    ///
30    /// The minimum [`Level`] a reporter should ship at this link cost.
31    pub fn threshold(self) -> Level {
32        match self {
33            LinkCost::Free => Level::Trace,
34            LinkCost::Metered => Level::Info,
35            LinkCost::Expensive => Level::Warn,
36            LinkCost::Offline => Level::Error,
37        }
38    }
39}
40
41/// A point-in-time summary of a reporter's counters.
42///
43/// This is what a node ships periodically in place of the raw event stream: a few
44/// integers that capture how many events occurred at each level and how many were
45/// shipped versus dropped, cheap to send even on a metered link.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub struct Snapshot {
48    /// The number of events seen at each level, indexed by the level's order from
49    /// [`Trace`](Level::Trace) to [`Error`](Level::Error).
50    pub by_level: [u32; LEVEL_COUNT],
51    /// How many events passed the filter and were shipped.
52    pub emitted: u32,
53    /// How many events were dropped by the filter.
54    pub dropped: u32,
55}
56
57/// Records telemetry events, ships the ones worth their bytes, and counts them all.
58///
59/// A reporter keeps a level threshold and forwards only events at or above it, while
60/// counting every event it sees - dropped or not - so the aggregate picture survives
61/// even when the link is too costly to ship detail. Call
62/// [`adapt_to`](Reporter::adapt_to) as the link cost changes to raise or lower the
63/// bar, and ship a [`snapshot`](Reporter::snapshot) of the counters periodically
64/// instead of the full stream.
65///
66/// # Examples
67///
68/// ```
69/// use pamoja_telemetry::{Event, Level, LinkCost, Reporter};
70///
71/// let mut reporter = Reporter::new(Level::Trace);
72///
73/// // On a metered link, routine debug events are dropped but a warning still ships.
74/// reporter.adapt_to(LinkCost::Metered);
75/// assert!(reporter.record(Event::debug("loop.tick")).is_none());
76/// assert!(reporter.record(Event::warn("battery.low")).is_some());
77///
78/// // Both events were still counted.
79/// assert_eq!(reporter.total(), 2);
80/// assert_eq!(reporter.dropped(), 1);
81/// ```
82pub struct Reporter {
83    threshold: Level,
84    counts: [u32; LEVEL_COUNT],
85    emitted: u32,
86}
87
88impl Reporter {
89    /// Creates a reporter that ships events at or above `threshold`.
90    ///
91    /// # Arguments
92    ///
93    /// * `threshold` - the minimum level to ship.
94    ///
95    /// # Returns
96    ///
97    /// A reporter with empty counters.
98    pub fn new(threshold: Level) -> Self {
99        Self {
100            threshold,
101            counts: [0; LEVEL_COUNT],
102            emitted: 0,
103        }
104    }
105
106    /// Returns the current ship threshold.
107    ///
108    /// # Returns
109    ///
110    /// The minimum level currently being shipped.
111    pub fn threshold(&self) -> Level {
112        self.threshold
113    }
114
115    /// Sets the ship threshold directly.
116    ///
117    /// # Arguments
118    ///
119    /// * `threshold` - the new minimum level to ship.
120    pub fn set_threshold(&mut self, threshold: Level) {
121        self.threshold = threshold;
122    }
123
124    /// Raises or lowers the threshold to match the current link cost.
125    ///
126    /// # Arguments
127    ///
128    /// * `cost` - how costly the link currently is.
129    pub fn adapt_to(&mut self, cost: LinkCost) {
130        self.threshold = cost.threshold();
131    }
132
133    /// Records an event, returning it to ship if it clears the threshold.
134    ///
135    /// The event is counted whether or not it is shipped, so the aggregate counts
136    /// stay complete even while detail is held back. A count that reaches `u32::MAX`
137    /// stays there rather than wrapping to zero, so a node that runs for years reports
138    /// a full counter, never a small one.
139    ///
140    /// # Arguments
141    ///
142    /// * `event` - the event to record.
143    ///
144    /// # Returns
145    ///
146    /// `Some(event)` if it should be shipped, or `None` if it was dropped by the
147    /// threshold.
148    pub fn record(&mut self, event: Event) -> Option<Event> {
149        let count = &mut self.counts[event.level as usize];
150        *count = count.saturating_add(1);
151        if event.level >= self.threshold {
152            self.emitted = self.emitted.saturating_add(1);
153            Some(event)
154        } else {
155            None
156        }
157    }
158
159    /// Returns how many events have been seen at `level`, shipped or not.
160    ///
161    /// # Arguments
162    ///
163    /// * `level` - the level to count.
164    ///
165    /// # Returns
166    ///
167    /// The number of events recorded at that level.
168    pub fn count(&self, level: Level) -> u32 {
169        self.counts[level as usize]
170    }
171
172    /// Returns the total number of events seen across all levels.
173    ///
174    /// # Returns
175    ///
176    /// The total count, held at `u32::MAX` once it gets there.
177    pub fn total(&self) -> u32 {
178        self.counts
179            .iter()
180            .fold(0u32, |total, count| total.saturating_add(*count))
181    }
182
183    /// Returns how many events passed the threshold and were shipped.
184    ///
185    /// # Returns
186    ///
187    /// The emitted count.
188    pub fn emitted(&self) -> u32 {
189        self.emitted
190    }
191
192    /// Returns how many events were dropped by the threshold.
193    ///
194    /// # Returns
195    ///
196    /// The dropped count.
197    pub fn dropped(&self) -> u32 {
198        self.total().saturating_sub(self.emitted)
199    }
200
201    /// Returns a snapshot of the counters to ship in place of the raw stream.
202    ///
203    /// # Returns
204    ///
205    /// A [`Snapshot`] of the per-level counts and the emitted and dropped totals.
206    pub fn snapshot(&self) -> Snapshot {
207        Snapshot {
208            by_level: self.counts,
209            emitted: self.emitted,
210            dropped: self.dropped(),
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn it_ships_at_or_above_the_threshold() {
221        let mut reporter = Reporter::new(Level::Info);
222        assert!(reporter.record(Event::debug("d")).is_none());
223        assert!(reporter.record(Event::info("i")).is_some());
224        assert!(reporter.record(Event::error("e")).is_some());
225    }
226
227    #[test]
228    fn it_counts_every_event_even_when_dropped() {
229        let mut reporter = Reporter::new(Level::Warn);
230        reporter.record(Event::debug("d"));
231        reporter.record(Event::debug("d"));
232        reporter.record(Event::error("e"));
233        assert_eq!(reporter.count(Level::Debug), 2);
234        assert_eq!(reporter.count(Level::Error), 1);
235        assert_eq!(reporter.total(), 3);
236        assert_eq!(reporter.emitted(), 1);
237        assert_eq!(reporter.dropped(), 2);
238    }
239
240    #[test]
241    fn link_cost_sets_the_threshold() {
242        let mut reporter = Reporter::new(Level::Trace);
243        reporter.adapt_to(LinkCost::Metered);
244        assert_eq!(reporter.threshold(), Level::Info);
245        reporter.adapt_to(LinkCost::Expensive);
246        assert_eq!(reporter.threshold(), Level::Warn);
247        reporter.adapt_to(LinkCost::Offline);
248        assert_eq!(reporter.threshold(), Level::Error);
249        reporter.adapt_to(LinkCost::Free);
250        assert_eq!(reporter.threshold(), Level::Trace);
251    }
252
253    #[test]
254    fn a_snapshot_summarizes_the_counters() {
255        let mut reporter = Reporter::new(Level::Info);
256        reporter.record(Event::trace("t"));
257        reporter.record(Event::info("i"));
258        reporter.record(Event::warn("w"));
259        let snapshot = reporter.snapshot();
260        assert_eq!(snapshot.by_level[Level::Trace as usize], 1);
261        assert_eq!(snapshot.by_level[Level::Info as usize], 1);
262        assert_eq!(snapshot.by_level[Level::Warn as usize], 1);
263        assert_eq!(snapshot.emitted, 2); // info and warn
264        assert_eq!(snapshot.dropped, 1); // trace
265    }
266
267    #[test]
268    fn a_full_counter_holds_rather_than_wrapping() {
269        let mut reporter = Reporter::new(Level::Info);
270        reporter.counts = [0, u32::MAX - 1, u32::MAX - 1, 0, 0];
271        reporter.emitted = u32::MAX - 1;
272        reporter.record(Event::debug("d"));
273        reporter.record(Event::debug("d"));
274        reporter.record(Event::info("i"));
275        reporter.record(Event::info("i"));
276        assert_eq!(reporter.count(Level::Debug), u32::MAX);
277        assert_eq!(reporter.count(Level::Info), u32::MAX);
278        assert_eq!(reporter.emitted(), u32::MAX);
279        assert_eq!(reporter.total(), u32::MAX);
280        assert_eq!(reporter.dropped(), 0);
281    }
282}