Skip to main content

teksilo_telemetry/
dynamic_reporter.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Runtime-switchable wrapper holding both anonymous-mode and
5//! pseudonymous-mode adapters.
6//!
7//! `DynamicReporter` implements [`UsageReporter`] by forwarding to
8//! whichever inner adapter matches the currently-active
9//! [`TelemetryMode`]. The mode is held in an `RwLock<TelemetryMode>`;
10//! `PrivacySettings` flips it via `set_active_mode`.
11//!
12//! Emission is gated by the [`ConsentStore`]'s `is_granted`. When
13//! consent is not granted, `record` short-circuits without touching
14//! the inner adapter.
15
16use std::cell::Cell;
17use std::rc::Rc;
18use std::sync::Arc;
19
20use teksilo_core::Signal;
21use teksilo_core::telemetry::{
22    ConsentScope, Event, RemoteDataExport, TelemetryError, UsageReporter,
23};
24
25use crate::bundle::TelemetryMode;
26use crate::consent::ConsentStore;
27use crate::queue::{EventQueue, InMemoryEventQueue};
28
29/// Forwarding wrapper that routes to one of two adapters at runtime.
30///
31/// Single-threaded — held inside `OpenedTelemetry` as
32/// `Rc<DynamicReporter>` and registered into the app-state registry.
33///
34/// `record()` tees each event into a `recent_log` ring buffer in
35/// addition to forwarding to the active adapter. The `PrivacySettings`
36/// widget reads `recent_log` to populate the "Inspect data sent"
37/// accordion. The recent log is **not** the adapter's outbound queue
38/// — events stay in the recent log even after the adapter has flushed
39/// them, until evicted by the ring buffer's capacity.
40pub struct DynamicReporter {
41    anonymous: Option<Rc<dyn UsageReporter>>,
42    pseudonymous: Option<Rc<dyn UsageReporter>>,
43    active: Cell<TelemetryMode>,
44    consent: ConsentStore,
45    recent_log: Arc<InMemoryEventQueue>,
46    /// Monotonic counter bumped on every `record()` and `discard_pending()`.
47    /// The `PrivacySettings` widget binds to this signal so its
48    /// "Inspect data sent" accordion auto-rebuilds when new events
49    /// land. Living on `DynamicReporter` (which is `Rc`-shared on
50    /// the UI thread) keeps `Signal`'s thread-affinity contract
51    /// honored — record() is called only from the UI-thread
52    /// dispatch tap.
53    recent_log_revision: Signal<u64>,
54}
55
56impl DynamicReporter {
57    pub fn new(
58        anonymous: Option<Rc<dyn UsageReporter>>,
59        pseudonymous: Option<Rc<dyn UsageReporter>>,
60        default: TelemetryMode,
61        consent: ConsentStore,
62        recent_log: Arc<InMemoryEventQueue>,
63    ) -> Self {
64        debug_assert!(
65            anonymous.is_some() || pseudonymous.is_some(),
66            "DynamicReporter needs at least one adapter",
67        );
68        Self {
69            anonymous,
70            pseudonymous,
71            active: Cell::new(default),
72            consent,
73            recent_log,
74            recent_log_revision: Signal::new(0),
75        }
76    }
77
78    pub fn recent_log(&self) -> &Arc<InMemoryEventQueue> {
79        &self.recent_log
80    }
81
82    /// Signal bumped on every event recorded into the recent-log
83    /// ring buffer (and on `discard_pending`). The `PrivacySettings`
84    /// widget binds to it for `BindingLevel::Rebuild` so the
85    /// "Inspect data sent" accordion stays in sync without polling.
86    pub fn recent_log_revision(&self) -> Signal<u64> {
87        self.recent_log_revision.clone()
88    }
89
90    fn bump_revision(&self) {
91        let v = self.recent_log_revision.get();
92        self.recent_log_revision.set(v.wrapping_add(1));
93    }
94
95    pub fn active_mode(&self) -> TelemetryMode {
96        self.active.get()
97    }
98
99    /// Atomically swap the active mode. Caller is responsible for the
100    /// pre/post-conditions: `erase_remote_data` before leaving
101    /// pseudonymous, `discard_pending` to drop the queue, then
102    /// `consent.reset()` to force re-prompt.
103    pub fn set_active_mode(&self, mode: TelemetryMode) {
104        self.active.set(mode);
105    }
106
107    /// `true` iff the bundle was constructed with both adapters; only
108    /// then is the mode switch UI shown.
109    pub fn supports_mode_switch(&self) -> bool {
110        self.anonymous.is_some() && self.pseudonymous.is_some()
111    }
112
113    /// `true` iff the given mode has an adapter configured.
114    pub fn has_mode(&self, mode: TelemetryMode) -> bool {
115        match mode {
116            TelemetryMode::Anonymous => self.anonymous.is_some(),
117            TelemetryMode::Pseudonymous => self.pseudonymous.is_some(),
118        }
119    }
120
121    fn active_adapter(&self) -> Option<&Rc<dyn UsageReporter>> {
122        match self.active_mode() {
123            TelemetryMode::Anonymous => self.anonymous.as_ref(),
124            TelemetryMode::Pseudonymous => self.pseudonymous.as_ref(),
125        }
126    }
127
128    pub fn consent(&self) -> &ConsentStore {
129        &self.consent
130    }
131}
132
133impl UsageReporter for DynamicReporter {
134    fn record(&self, event: &Event<'_>) {
135        if !self.consent.is_granted() {
136            return;
137        }
138        // Tee into the user-facing recent-log ring buffer first; the
139        // user can inspect what was actually emitted regardless of
140        // adapter outcome.
141        self.recent_log.push(event.to_owned());
142        self.bump_revision();
143        if let Some(adapter) = self.active_adapter() {
144            adapter.record(event);
145        }
146    }
147
148    fn flush(&self) -> Result<(), TelemetryError> {
149        // Flush both — events buffered before a mode switch should
150        // still go out via the original adapter.
151        if let Some(a) = &self.anonymous {
152            a.flush()?;
153        }
154        if let Some(p) = &self.pseudonymous {
155            p.flush()?;
156        }
157        Ok(())
158    }
159
160    fn discard_pending(&self) -> Result<(), TelemetryError> {
161        // Drop the user-facing recent-log ring buffer alongside the
162        // adapters' outbound buffers so the privacy widget's
163        // "Inspect data sent" panel reflects the wipe immediately.
164        self.recent_log.discard_all();
165        self.bump_revision();
166        if let Some(a) = &self.anonymous {
167            a.discard_pending()?;
168        }
169        if let Some(p) = &self.pseudonymous {
170            p.discard_pending()?;
171        }
172        Ok(())
173    }
174
175    fn erase_remote_data(&self) -> Result<(), TelemetryError> {
176        // Only the pseudonymous adapter has erase semantics; anonymous
177        // adapters return ErasureUnsupported. We forward to whichever
178        // mode is active — the widget hides the button on anonymous.
179        match self.active_adapter() {
180            Some(a) => a.erase_remote_data(),
181            None => Err(TelemetryError::ErasureUnsupported),
182        }
183    }
184
185    fn fetch_remote_data(&self) -> Result<RemoteDataExport, TelemetryError> {
186        match self.active_adapter() {
187            Some(a) => a.fetch_remote_data(),
188            None => Err(TelemetryError::FetchUnsupported),
189        }
190    }
191
192    fn install_id(&self) -> Option<&str> {
193        // Caller signature returns `Option<&str>` borrowing from the
194        // reporter — we have to forward through the active adapter.
195        // `Arc::as_ref` then `dyn UsageReporter::install_id` returns
196        // `Option<&str>` borrowed from the adapter. Lifetime works
197        // because the adapter outlives `&self`.
198        match self.active_mode() {
199            TelemetryMode::Anonymous => self.anonymous.as_deref().and_then(|a| a.install_id()),
200            TelemetryMode::Pseudonymous => {
201                self.pseudonymous.as_deref().and_then(|a| a.install_id())
202            }
203        }
204    }
205
206    fn adapter_name(&self) -> &'static str {
207        match self.active_adapter() {
208            Some(a) => a.adapter_name(),
209            None => "none",
210        }
211    }
212
213    fn endpoint(&self) -> &str {
214        match self.active_mode() {
215            TelemetryMode::Anonymous => self.anonymous.as_deref().map_or("", |a| a.endpoint()),
216            TelemetryMode::Pseudonymous => {
217                self.pseudonymous.as_deref().map_or("", |a| a.endpoint())
218            }
219        }
220    }
221
222    fn supported_scopes(&self) -> ConsentScope {
223        match self.active_adapter() {
224            Some(a) => a.supported_scopes(),
225            None => ConsentScope::none(),
226        }
227    }
228}
229
230impl std::fmt::Debug for DynamicReporter {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        f.debug_struct("DynamicReporter")
233            .field("active_mode", &self.active_mode())
234            .field("has_anonymous", &self.anonymous.is_some())
235            .field("has_pseudonymous", &self.pseudonymous.is_some())
236            .finish()
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::stub::StubReporter;
244    use std::time::Duration;
245    use teksilo_settings::AppPaths;
246    use tempfile::tempdir;
247
248    fn make(
249        anon: bool,
250        pseudo: bool,
251        default: TelemetryMode,
252    ) -> (DynamicReporter, ConsentStore, tempfile::TempDir) {
253        let dir = tempdir().unwrap();
254        let paths = AppPaths::for_testing(dir.path());
255        let consent = ConsentStore::open(&paths, Duration::ZERO, 1, "stub://").unwrap();
256        let anonymous: Option<Rc<dyn UsageReporter>> =
257            anon.then(|| Rc::new(StubReporter::anonymous()) as _);
258        let pseudonymous: Option<Rc<dyn UsageReporter>> =
259            pseudo.then(|| Rc::new(StubReporter::pseudonymous("uuid-1")) as _);
260        let recent_log = Arc::new(InMemoryEventQueue::with_capacity(64));
261        let dyn_r = DynamicReporter::new(
262            anonymous,
263            pseudonymous,
264            default,
265            consent.clone(),
266            recent_log,
267        );
268        (dyn_r, consent, dir)
269    }
270
271    fn make_event(
272        name: &'static str,
273    ) -> (Event<'static>, [teksilo_core::telemetry::Prop<'static>; 0]) {
274        let props: [teksilo_core::telemetry::Prop<'static>; 0] = [];
275        // The borrow checker requires the props slice to outlive Event;
276        // tests construct fresh each time so we return both.
277        (
278            Event {
279                name,
280                category: teksilo_core::telemetry::EventCategory::Intent,
281                timestamp: std::time::SystemTime::UNIX_EPOCH,
282                install_id: None,
283                session_id: "s",
284                schema_version: 1,
285                props: &[],
286            },
287            props,
288        )
289    }
290
291    #[test]
292    fn record_drops_when_consent_unknown() {
293        let (r, _consent, _dir) = make(true, false, TelemetryMode::Anonymous);
294        let (e, _) = make_event("intent.dispatched");
295        r.record(&e);
296        // Stub captures via downcast; we assert via the public API.
297        assert!(matches!(
298            r.fetch_remote_data(),
299            Err(TelemetryError::FetchUnsupported)
300        ));
301        // Anonymous stub doesn't expose its internal vec; accept the
302        // assertion via "no error and no events fetched".
303    }
304
305    #[test]
306    fn record_routes_to_active_adapter() {
307        let (r, consent, _dir) = make(true, true, TelemetryMode::Pseudonymous);
308        consent.grant(ConsentScope::all(), "stub://").unwrap();
309
310        let (e, _) = make_event("intent.dispatched");
311        r.record(&e);
312
313        // We routed to the pseudonymous adapter — fetch should return 1.
314        let export = r.fetch_remote_data().unwrap();
315        assert_eq!(export.events.len(), 1);
316    }
317
318    #[test]
319    fn mode_switch_changes_active_adapter() {
320        let (r, consent, _dir) = make(true, true, TelemetryMode::Anonymous);
321        consent.grant(ConsentScope::all(), "stub://").unwrap();
322
323        let (e, _) = make_event("intent.dispatched");
324        r.record(&e);
325        // Anonymous mode → fetch returns FetchUnsupported.
326        assert!(matches!(
327            r.fetch_remote_data(),
328            Err(TelemetryError::FetchUnsupported)
329        ));
330
331        // Switch to pseudonymous and emit again.
332        r.set_active_mode(TelemetryMode::Pseudonymous);
333        r.record(&e);
334        let export = r.fetch_remote_data().unwrap();
335        assert_eq!(export.events.len(), 1); // only the post-switch event
336    }
337
338    #[test]
339    fn supports_mode_switch_only_with_both() {
340        let (r1, _, _d1) = make(true, false, TelemetryMode::Anonymous);
341        assert!(!r1.supports_mode_switch());
342        let (r2, _, _d2) = make(true, true, TelemetryMode::Anonymous);
343        assert!(r2.supports_mode_switch());
344    }
345}