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}