Skip to main content

teksilo_core/telemetry/
reporter.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `UsageReporter` trait and consent enums.
5//!
6//! The trait is **synchronous**. Adapters that perform
7//! HTTP transport spawn their own worker threads or use blocking
8//! clients; the dispatch tap MUST NOT block the UI thread, so
9//! [`UsageReporter::record`] queues and returns. Async upgrade is
10//! possible later without changing the surface (return
11//! `Pin<Box<dyn Future>>` for the methods that need it).
12
13use std::io;
14use std::rc::Rc;
15
16use super::event::{Event, RemoteDataExport};
17
18/// The observability sink. Implemented by adapter crates
19/// (`teksilo-analytics-plausible`, `teksilo-analytics-posthog`, etc.) and
20/// by `teksilo-telemetry::DynamicReporter` (which forwards to whichever
21/// concrete adapter is currently active).
22///
23/// Object-safe — registered into the app-state registry as
24/// `Rc<dyn UsageReporter>` and looked up by trait-object pointer.
25///
26/// **Single-threaded.** Teksilo is single-threaded by design (the
27/// arena, `Signal<T>`, `ListModel<T>` are all `Rc<RefCell<>>`-shaped).
28/// Adapters that need a worker thread for HTTP transport bridge
29/// internally with channels and own a separate `Send`-able state;
30/// the trait surface itself stays on the UI thread.
31///
32/// Implementations MUST gate emission on consent state internally.
33/// The dispatch tap calls `record` unconditionally; the reporter
34/// drops the event when consent is not `Granted`.
35pub trait UsageReporter: 'static {
36    /// Invoked synchronously from any thread. MUST NOT block the
37    /// caller (queue and return). Drops events when consent is not
38    /// `Granted`. Errors are buffered internally — there is no
39    /// return value because the caller cannot meaningfully react.
40    fn record(&self, event: &Event<'_>);
41
42    /// Best-effort drain of the on-disk queue. Called on graceful
43    /// exit. **Not** called on consent revocation — see
44    /// [`Self::discard_pending`].
45    fn flush(&self) -> Result<(), TelemetryError> {
46        Ok(())
47    }
48
49    /// Drop the queue without sending. Called when consent is
50    /// revoked, when the mode is switched, or when the user clicks
51    /// "Erase my data". Once consent is `Denied` or `Unknown`, the
52    /// buffered events are no longer permitted to leave the device.
53    fn discard_pending(&self) -> Result<(), TelemetryError> {
54        Ok(())
55    }
56
57    /// GDPR Art. 17. Pseudonymous mode: send DELETE keyed by
58    /// install_id; clear the local queue. Anonymous mode: returns
59    /// [`TelemetryError::ErasureUnsupported`] so the widget can hide
60    /// the button.
61    fn erase_remote_data(&self) -> Result<(), TelemetryError> {
62        Err(TelemetryError::ErasureUnsupported)
63    }
64
65    /// GDPR Art. 15 + 20. Pseudonymous mode: fetch all server-side
66    /// events for this install_id as a [`RemoteDataExport`].
67    /// Anonymous mode: returns [`TelemetryError::FetchUnsupported`].
68    fn fetch_remote_data(&self) -> Result<RemoteDataExport, TelemetryError> {
69        Err(TelemetryError::FetchUnsupported)
70    }
71
72    /// `Some(uuid)` in pseudonymous mode, `None` in anonymous mode.
73    /// Surfaced verbatim by the consent widget for user inspection.
74    fn install_id(&self) -> Option<&str> {
75        None
76    }
77
78    /// `"plausible"`, `"posthog"`, `"otlp"`, `"stub"`. Shown in the
79    /// widget's "what gets sent" tab.
80    fn adapter_name(&self) -> &'static str;
81
82    /// Endpoint URL displayed verbatim in the consent widget.
83    fn endpoint(&self) -> &str;
84
85    /// Drives the consent widget toggle group: which scopes does
86    /// this adapter actually use? Toggles for unsupported scopes
87    /// are hidden, not just disabled.
88    fn supported_scopes(&self) -> ConsentScope {
89        ConsentScope::all()
90    }
91}
92
93/// Registration type for the dispatch tap.
94///
95/// `teksilo-telemetry::TelemetryBundle::open` constructs one of these
96/// and registers it into `app_state`. The dispatch tap in
97/// `crate::widget_tree::WidgetTree::dispatch_intent` looks it up
98/// by `TypeId`, calls `record` if found.
99///
100/// Carries the metadata the tap needs to assemble a complete
101/// `Event` — `session_id` (per-process random) and `schema_version`
102/// (codegen'd constant from the YAML manifest).
103pub struct TelemetryContext {
104    pub reporter: Rc<dyn UsageReporter>,
105    /// Per-process random session id. Not persisted across restarts.
106    pub session_id: String,
107    /// Event-schema version at this build. Bumped whenever the
108    /// framework's events.yaml gains, drops, or reshapes an event.
109    pub schema_version: u32,
110}
111
112impl std::fmt::Debug for TelemetryContext {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.debug_struct("TelemetryContext")
115            .field("session_id", &self.session_id)
116            .field("schema_version", &self.schema_version)
117            .field("adapter", &self.reporter.adapter_name())
118            .finish()
119    }
120}
121
122#[derive(Debug, thiserror::Error)]
123pub enum TelemetryError {
124    /// Anonymous mode: no linkable data to erase.
125    #[error("erasure unsupported (anonymous mode)")]
126    ErasureUnsupported,
127    /// Anonymous mode: no per-user query surface.
128    #[error("fetch unsupported (anonymous mode)")]
129    FetchUnsupported,
130    /// Backend has no DELETE endpoint configured (OTLP variants).
131    #[error("erasure unsupported by configured backend")]
132    ErasureUnsupportedByBackend,
133    /// Backend has no query endpoint configured.
134    #[error("fetch unsupported by configured backend")]
135    FetchUnsupportedByBackend,
136    #[error("network error: {0}")]
137    Network(#[from] io::Error),
138    #[error("server returned {status}: {body}")]
139    Server { status: u16, body: String },
140    /// Adapter rate-limited the export (Art. 12(3) one-month SLA
141    /// applies — the controller must honor the request out-of-band).
142    #[error("rate-limited; retry later")]
143    QuotaExceeded,
144    /// Consent state forbids the operation right now.
145    #[error("consent not granted")]
146    NotConsented,
147    #[error("{0}")]
148    Other(String),
149}
150
151// --- Consent state --------------------------------------------------
152
153/// Top-level consent state, persisted via `teksilo-telemetry::ConsentStore`.
154#[derive(Clone, Debug, PartialEq, Eq, Default)]
155pub enum ConsentState {
156    /// Pre-decision. The widget must prompt; no events emitted.
157    #[default]
158    Unknown,
159    /// User granted consent for the listed scopes.
160    Granted(ConsentScope),
161    /// User explicitly declined. No events emitted.
162    Denied,
163}
164
165impl ConsentState {
166    pub fn is_granted(&self) -> bool {
167        matches!(self, ConsentState::Granted(_))
168    }
169
170    pub fn scope(&self) -> Option<&ConsentScope> {
171        match self {
172            ConsentState::Granted(s) => Some(s),
173            _ => None,
174        }
175    }
176}
177
178/// Per-purpose consent toggles. Each is independent.
179///
180/// Defaults to **all-false**: an `Unknown` state translates to "no
181/// scopes granted" until the user explicitly opts in. The widget shows
182/// a toggle row per scope and an Accept-all / Reject-all pair (CNIL
183/// equal-prominence rule).
184#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
185pub struct ConsentScope {
186    /// Anonymous usage metrics (intent dispatches, lifecycle, census).
187    /// Anonymous mode always uses this scope — no other scope makes
188    /// sense without a stable id.
189    pub anonymous_metrics: bool,
190    /// Crash reports. Reserved — no transport yet.
191    pub crash_reports: bool,
192    /// Feature-flag fetches. Reserved — no transport yet.
193    pub feature_flags: bool,
194    /// Session recording. Reserved; not implemented (PII risk).
195    pub session_recording: bool,
196}
197
198impl ConsentScope {
199    /// All scopes off — the default, and the value used for `Denied`.
200    pub fn none() -> Self {
201        Self::default()
202    }
203
204    /// All scopes on — convenience for tests and "Accept all".
205    pub fn all() -> Self {
206        Self {
207            anonymous_metrics: true,
208            crash_reports: true,
209            feature_flags: true,
210            session_recording: false, // reserved, never auto-on
211        }
212    }
213
214    pub fn anonymous_metrics_only() -> Self {
215        Self {
216            anonymous_metrics: true,
217            ..Self::default()
218        }
219    }
220
221    /// True if any toggle is on.
222    pub fn any(&self) -> bool {
223        self.anonymous_metrics || self.crash_reports || self.feature_flags || self.session_recording
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn consent_state_defaults_to_unknown() {
233        let s = ConsentState::default();
234        assert!(matches!(s, ConsentState::Unknown));
235        assert!(!s.is_granted());
236    }
237
238    #[test]
239    fn scope_all_excludes_session_recording() {
240        let s = ConsentScope::all();
241        assert!(s.anonymous_metrics);
242        assert!(s.crash_reports);
243        assert!(s.feature_flags);
244        assert!(!s.session_recording);
245    }
246
247    #[test]
248    fn telemetry_error_display() {
249        let e = TelemetryError::ErasureUnsupported;
250        assert!(e.to_string().contains("anonymous"));
251    }
252}