Skip to main content

teksilo_telemetry/
bundle.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TelemetryBundle` — declarative configuration for the
5//! `TeksiloAppBuilder` integration.
6//!
7//! Mirrors [`teksilo_settings::SettingsBundle`] / `OpenedSettings`:
8//! construct a bundle with `with_*` methods, `bundle.open(paths,
9//! settings)` returns ready-to-register handles, the app registers
10//! them in `app_state` and accesses via the `TelemetryExt` trait.
11
12use std::rc::Rc;
13use std::sync::Arc;
14use std::time::Duration;
15
16use teksilo_core::telemetry::UsageReporter;
17use teksilo_settings::{AppPaths, SettingsFileError, SettingsStore};
18
19use crate::consent::ConsentStore;
20use crate::dynamic_reporter::DynamicReporter;
21use crate::install_id::InstallId;
22use crate::queue::{EventQueue, InMemoryEventQueue};
23use crate::scopes::{TELEMETRY_ENDPOINT_OVERRIDE, TELEMETRY_REGION_OVERRIDE};
24
25/// Which privacy posture the reporter is operating in.
26#[derive(Copy, Clone, Debug, PartialEq, Eq)]
27pub enum TelemetryMode {
28    /// No client identifier transmitted. CNIL consent-exempt under
29    /// the audience-measurement self-assessment, GDPR Art. 6(1)(f)
30    /// basis. Adapter example: `teksilo-analytics-plausible`.
31    Anonymous,
32    /// Stable per-install UUID transmitted with every event. Requires
33    /// explicit consent under GDPR Art. 6(1)(a) + ePrivacy 5(3).
34    /// Adapter example: `teksilo-analytics-posthog`.
35    Pseudonymous,
36}
37
38#[derive(Copy, Clone, Debug, PartialEq, Eq)]
39pub enum DataResidencyRegion {
40    EU,
41    US,
42    Other,
43}
44
45impl DataResidencyRegion {
46    pub fn as_str(self) -> &'static str {
47        match self {
48            Self::EU => "EU",
49            Self::US => "US",
50            Self::Other => "other",
51        }
52    }
53}
54
55#[derive(Clone, Debug)]
56pub struct PrivacyPolicy {
57    pub data_processor_name: String,
58    pub privacy_policy_url: Option<String>,
59    pub data_residency_region: DataResidencyRegion,
60    /// Surfaced in the consent widget Art. 13 notice.
61    pub retention_days: u32,
62}
63
64impl Default for PrivacyPolicy {
65    fn default() -> Self {
66        Self {
67            data_processor_name: String::new(),
68            privacy_policy_url: None,
69            data_residency_region: DataResidencyRegion::EU,
70            retention_days: 395, // 13 months
71        }
72    }
73}
74
75#[derive(Debug, thiserror::Error)]
76pub enum TelemetryBundleError {
77    #[error(
78        "TelemetryBundle: no adapter configured (call .with_anonymous(...) or .with_pseudonymous(...))"
79    )]
80    NoAdapter,
81    #[error("TelemetryBundle: {0}")]
82    Settings(#[from] SettingsFileError),
83}
84
85/// Declarative configuration for the telemetry stack. Consume with
86/// [`open`](Self::open).
87#[derive(Clone)]
88pub struct TelemetryBundle {
89    anonymous: Option<Rc<dyn UsageReporter>>,
90    pseudonymous: Option<Rc<dyn UsageReporter>>,
91    default_mode: TelemetryMode,
92    event_schema_version: u32,
93    debounce: Duration,
94    policy: PrivacyPolicy,
95    recent_log_capacity: usize,
96}
97
98impl TelemetryBundle {
99    /// Empty bundle — at least one of `with_anonymous` /
100    /// `with_pseudonymous` must be called before `open`.
101    pub fn new(event_schema_version: u32) -> Self {
102        Self {
103            anonymous: None,
104            pseudonymous: None,
105            default_mode: TelemetryMode::Anonymous,
106            event_schema_version,
107            debounce: Duration::from_millis(500),
108            policy: PrivacyPolicy::default(),
109            recent_log_capacity: 200,
110        }
111    }
112
113    /// Capacity of the user-facing "recently emitted" ring buffer
114    /// surfaced by the `PrivacySettings` widget's "Inspect data sent"
115    /// accordion. Default: 200 events. Independent of any adapter's
116    /// own outbound queue.
117    pub fn with_recent_log_capacity(mut self, n: usize) -> Self {
118        self.recent_log_capacity = n.max(1);
119        self
120    }
121
122    /// Install the anonymous-mode adapter (e.g.
123    /// `teksilo-analytics-plausible`). The adapter's `install_id()`
124    /// must return `None`.
125    pub fn with_anonymous(mut self, reporter: Rc<dyn UsageReporter>) -> Self {
126        self.anonymous = Some(reporter);
127        self
128    }
129
130    /// Install the pseudonymous-mode adapter (e.g.
131    /// `teksilo-analytics-posthog`). The adapter's `install_id()` must
132    /// return `Some(uuid)` once configured.
133    pub fn with_pseudonymous(mut self, reporter: Rc<dyn UsageReporter>) -> Self {
134        self.pseudonymous = Some(reporter);
135        self
136    }
137
138    /// Which mode is active on first run before the user picks.
139    pub fn with_default_mode(mut self, mode: TelemetryMode) -> Self {
140        self.default_mode = mode;
141        self
142    }
143
144    pub fn with_debounce(mut self, debounce: Duration) -> Self {
145        self.debounce = debounce;
146        self
147    }
148
149    pub fn with_data_processor_name(mut self, name: impl Into<String>) -> Self {
150        self.policy.data_processor_name = name.into();
151        self
152    }
153
154    pub fn with_privacy_policy_url(mut self, url: impl Into<String>) -> Self {
155        self.policy.privacy_policy_url = Some(url.into());
156        self
157    }
158
159    pub fn with_data_residency_region(mut self, region: DataResidencyRegion) -> Self {
160        self.policy.data_residency_region = region;
161        self
162    }
163
164    pub fn with_retention_days(mut self, days: u32) -> Self {
165        self.policy.retention_days = days;
166        self
167    }
168
169    /// Open every requested service against `paths`. Reads the
170    /// runtime endpoint override from `settings` exactly once; the
171    /// override is propagated to adapters via their constructors
172    /// (each adapter is responsible for honoring it).
173    pub fn open(
174        self,
175        paths: &AppPaths,
176        settings: &SettingsStore,
177    ) -> Result<OpenedTelemetry, TelemetryBundleError> {
178        if self.anonymous.is_none() && self.pseudonymous.is_none() {
179            return Err(TelemetryBundleError::NoAdapter);
180        }
181
182        // Validate the requested default mode has an adapter.
183        let default_mode = match self.default_mode {
184            TelemetryMode::Anonymous if self.anonymous.is_some() => TelemetryMode::Anonymous,
185            TelemetryMode::Pseudonymous if self.pseudonymous.is_some() => {
186                TelemetryMode::Pseudonymous
187            }
188            // Fall back to whichever was actually configured.
189            _ => {
190                if self.anonymous.is_some() {
191                    TelemetryMode::Anonymous
192                } else {
193                    TelemetryMode::Pseudonymous
194                }
195            }
196        };
197
198        // Read the endpoint override (if any) for the consent re-prompt
199        // recipient-change check. Adapters are constructed before this
200        // function runs, so they already hold their endpoint by value;
201        // the override path is for adapters that subscribe to the
202        // signal directly. Only the recipient marker is stored.
203        let endpoint_override = settings.signal_for(&TELEMETRY_ENDPOINT_OVERRIDE).get();
204        let _region_override = settings.signal_for(&TELEMETRY_REGION_OVERRIDE).get();
205
206        let endpoint_for_consent = match (&self.anonymous, &self.pseudonymous) {
207            // Endpoint string used for the re-prompt recipient check.
208            // Prefer the active adapter's endpoint.
209            _ if !endpoint_override.is_empty() => endpoint_override.clone(),
210            (_, Some(p)) if matches!(default_mode, TelemetryMode::Pseudonymous) => {
211                p.endpoint().to_string()
212            }
213            (Some(a), _) => a.endpoint().to_string(),
214            (None, Some(p)) => p.endpoint().to_string(),
215            _ => unreachable!("validated above"),
216        };
217
218        let consent = ConsentStore::open(
219            paths,
220            self.debounce,
221            self.event_schema_version,
222            &endpoint_for_consent,
223        )?
224        .with_settings_mirror(settings.clone());
225
226        let install_id = if self.pseudonymous.is_some() {
227            Some(InstallId::open_or_create(paths, self.debounce)?)
228        } else {
229            None
230        };
231
232        let recent_log = Arc::new(InMemoryEventQueue::with_capacity(self.recent_log_capacity));
233        let reporter = Rc::new(DynamicReporter::new(
234            self.anonymous,
235            self.pseudonymous,
236            default_mode,
237            consent.clone(),
238            recent_log.clone(),
239        ));
240
241        Ok(OpenedTelemetry {
242            consent,
243            install_id,
244            reporter,
245            recent_log,
246            policy: self.policy,
247            event_schema_version: self.event_schema_version,
248        })
249    }
250}
251
252/// Outcome of [`TelemetryBundle::open`]. Cheap to clone (every contained
253/// service is `Rc`/`Arc`-shaped). Registered into `app_state` by
254/// `TeksiloAppBuilder::install_telemetry`.
255#[derive(Clone)]
256pub struct OpenedTelemetry {
257    pub consent: ConsentStore,
258    pub install_id: Option<InstallId>,
259    pub reporter: Rc<DynamicReporter>,
260    /// Ring buffer of recently-emitted events. `DynamicReporter::record`
261    /// tees every consent-gated event into this log in addition to
262    /// forwarding to the active adapter. Bounded by
263    /// `TelemetryBundle::with_recent_log_capacity` (default 200).
264    /// Read by the `PrivacySettings` "Inspect data sent" accordion;
265    /// **independent** of the adapter's outbound queue (events stay
266    /// in the recent log even after the adapter has flushed them).
267    pub recent_log: Arc<InMemoryEventQueue>,
268    pub policy: PrivacyPolicy,
269    pub event_schema_version: u32,
270}
271
272impl OpenedTelemetry {
273    /// Synchronous flush of consent + install_id files (the queue
274    /// flush is the adapter's responsibility — see
275    /// [`UsageReporter::flush`]).
276    pub fn flush_all(&self) -> Result<(), TelemetryBundleError> {
277        self.consent.flush_now()?;
278        if let Some(id) = &self.install_id {
279            id.flush_now()?;
280        }
281        Ok(())
282    }
283
284    /// Wipe the recent-log ring buffer and ask each adapter to drop
285    /// its outbound buffer. Called on consent revocation and on the
286    /// "Erase my data" flow.
287    ///
288    /// The recent-log discard happens inside `DynamicReporter::
289    /// discard_pending()` (so the revision signal is bumped from
290    /// the same place it's bumped on `record()`), not here.
291    pub fn discard_pending(&self) -> Result<(), teksilo_core::telemetry::TelemetryError> {
292        self.reporter.discard_pending()
293    }
294}
295
296impl std::fmt::Debug for OpenedTelemetry {
297    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
298        f.debug_struct("OpenedTelemetry")
299            .field("consent", &self.consent)
300            .field("install_id", &self.install_id)
301            .field("policy", &self.policy)
302            .field("recent_log_len", &self.recent_log.len())
303            .field("event_schema_version", &self.event_schema_version)
304            .finish()
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use crate::stub::StubReporter;
312    use std::time::Duration;
313    use tempfile::tempdir;
314
315    #[test]
316    fn empty_bundle_errors() {
317        let dir = tempdir().unwrap();
318        let paths = AppPaths::for_testing(dir.path());
319        let store =
320            SettingsStore::open_with_delay(paths.config_file("general"), Duration::ZERO).unwrap();
321        let bundle = TelemetryBundle::new(1);
322        let err = bundle.open(&paths, &store).unwrap_err();
323        assert!(matches!(err, TelemetryBundleError::NoAdapter));
324    }
325
326    #[test]
327    fn anonymous_only_bundle_opens() {
328        let dir = tempdir().unwrap();
329        let paths = AppPaths::for_testing(dir.path());
330        let store =
331            SettingsStore::open_with_delay(paths.config_file("general"), Duration::ZERO).unwrap();
332        let opened = TelemetryBundle::new(1)
333            .with_anonymous(Rc::new(StubReporter::anonymous()))
334            .with_default_mode(TelemetryMode::Anonymous)
335            .with_debounce(Duration::ZERO)
336            .open(&paths, &store)
337            .unwrap();
338        assert!(opened.install_id.is_none());
339        assert_eq!(opened.reporter.active_mode(), TelemetryMode::Anonymous);
340    }
341
342    #[test]
343    fn pseudonymous_bundle_creates_install_id() {
344        let dir = tempdir().unwrap();
345        let paths = AppPaths::for_testing(dir.path());
346        let store =
347            SettingsStore::open_with_delay(paths.config_file("general"), Duration::ZERO).unwrap();
348        let opened = TelemetryBundle::new(1)
349            .with_pseudonymous(Rc::new(StubReporter::pseudonymous("u")))
350            .with_default_mode(TelemetryMode::Pseudonymous)
351            .with_debounce(Duration::ZERO)
352            .open(&paths, &store)
353            .unwrap();
354        assert!(opened.install_id.is_some());
355        let id = opened.install_id.as_ref().unwrap().get();
356        assert!(!id.is_empty());
357    }
358
359    #[test]
360    fn default_mode_falls_back_when_unsupported() {
361        let dir = tempdir().unwrap();
362        let paths = AppPaths::for_testing(dir.path());
363        let store =
364            SettingsStore::open_with_delay(paths.config_file("general"), Duration::ZERO).unwrap();
365        // Request pseudonymous default but only ship anonymous adapter.
366        let opened = TelemetryBundle::new(1)
367            .with_anonymous(Rc::new(StubReporter::anonymous()))
368            .with_default_mode(TelemetryMode::Pseudonymous)
369            .with_debounce(Duration::ZERO)
370            .open(&paths, &store)
371            .unwrap();
372        assert_eq!(opened.reporter.active_mode(), TelemetryMode::Anonymous);
373    }
374}