Skip to main content

teksilo_core/telemetry/
event.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Event types passed to [`UsageReporter::record`](super::UsageReporter::record).
5//!
6//! Events are zero-copy borrowed structures so they can be assembled on
7//! the stack inside the dispatch path without allocation. Adapters that
8//! need to defer transmission convert to [`OwnedEvent`] before queueing.
9
10use std::collections::BTreeMap;
11use std::time::SystemTime;
12
13use serde::{Deserialize, Serialize};
14
15/// A telemetry event in flight.
16///
17/// Constructed by the codegen'd `emit_*` helpers in `teksilo-telemetry`
18/// (or hand-written for framework events) and handed to a reporter
19/// synchronously. The borrowed `'a` lifetime keeps the call site
20/// allocation-free — adapters that buffer events MUST convert to
21/// [`OwnedEvent`] before queueing.
22#[derive(Debug)]
23pub struct Event<'a> {
24    /// Stable, dev-authored event name (`"intent.dispatched"`,
25    /// `"lifecycle.app_started"`). Always a `&'static str` literal.
26    pub name: &'static str,
27    pub category: EventCategory,
28    pub timestamp: SystemTime,
29    /// `Some(uuid)` in pseudonymous mode, `None` in anonymous mode.
30    pub install_id: Option<&'a str>,
31    /// Per-process random session id. Not persisted across restarts.
32    pub session_id: &'a str,
33    /// Event-schema version at emission time. Used for server-side
34    /// schema validation and the consent re-prompt rule.
35    pub schema_version: u32,
36    pub props: &'a [Prop<'a>],
37}
38
39impl<'a> Event<'a> {
40    pub fn to_owned(&self) -> OwnedEvent {
41        OwnedEvent {
42            name: self.name.to_owned(),
43            category: self.category,
44            timestamp: self.timestamp,
45            install_id: self.install_id.map(str::to_owned),
46            session_id: self.session_id.to_owned(),
47            schema_version: self.schema_version,
48            props: self.props.iter().map(Prop::to_owned).collect(),
49        }
50    }
51}
52
53#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
54pub enum EventCategory {
55    Intent,
56    Lifecycle,
57    Navigation,
58    Census,
59    Custom,
60}
61
62impl EventCategory {
63    pub fn as_str(self) -> &'static str {
64        match self {
65            Self::Intent => "intent",
66            Self::Lifecycle => "lifecycle",
67            Self::Navigation => "navigation",
68            Self::Census => "census",
69            Self::Custom => "custom",
70        }
71    }
72}
73
74/// One key/value pair on an event. Keys are always `&'static str`.
75#[derive(Debug, Clone)]
76pub struct Prop<'a> {
77    pub key: &'static str,
78    pub value: PropValue<'a>,
79}
80
81impl<'a> Prop<'a> {
82    pub fn to_owned(&self) -> OwnedProp {
83        OwnedProp {
84            key: self.key.to_owned(),
85            value: self.value.to_owned(),
86        }
87    }
88}
89
90/// Closed enum of allowlisted property values. There is **no** `String`
91/// variant — anything dynamic must be length-bounded by the schema or
92/// pre-bucketed. This is the type-system enforcement of the data-
93/// minimisation rule: an app author physically cannot pass a runtime
94/// `String` from a `TextField` because the codegen'd emit signature
95/// won't accept one.
96#[derive(Debug, Clone)]
97pub enum PropValue<'a> {
98    /// `&'static str` — for `dev_static` schema properties (intent
99    /// names, source enums, etc.).
100    StaticStr(&'static str),
101    /// `&'a str` — for properties the schema marks as bounded-length
102    /// (locale code, app version). The caller is responsible for the
103    /// length bound; codegen enforces it at the emit-fn signature.
104    BoundedStr(&'a str),
105    U32(u32),
106    I64(i64),
107    /// Pre-bucketed float. Raw `f64` is intentionally absent — high-
108    /// entropy floats are a fingerprinting risk and must be bucketed
109    /// at the call site.
110    F64Bucket(F64Bucket),
111    Bool(bool),
112    /// Type-erased enum variant — the variant's `&'static str` name.
113    Enum {
114        variant: &'static str,
115    },
116    /// Histogram of `(static_key, count)`. Used by `widget.census`.
117    HistogramStrU32(&'a [(&'static str, u32)]),
118}
119
120impl<'a> PropValue<'a> {
121    pub fn to_owned(&self) -> OwnedPropValue {
122        match self {
123            Self::StaticStr(s) => OwnedPropValue::Str((*s).to_owned()),
124            Self::BoundedStr(s) => OwnedPropValue::Str((*s).to_owned()),
125            Self::U32(v) => OwnedPropValue::U32(*v),
126            Self::I64(v) => OwnedPropValue::I64(*v),
127            Self::F64Bucket(b) => OwnedPropValue::F64Bucket(*b),
128            Self::Bool(v) => OwnedPropValue::Bool(*v),
129            Self::Enum { variant } => OwnedPropValue::Str((*variant).to_owned()),
130            Self::HistogramStrU32(entries) => OwnedPropValue::HistogramStrU32(
131                entries.iter().map(|(k, v)| ((*k).to_owned(), *v)).collect(),
132            ),
133        }
134    }
135}
136
137#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
138pub struct F64Bucket {
139    /// Inclusive lower bound of the bucket, encoded as a
140    /// platform-independent fixed-point. The pair `(min_x100, max_x100)`
141    /// = `(120, 250)` represents `[1.20, 2.50)`.
142    pub min_x100: i64,
143    pub max_x100: i64,
144}
145
146// --- Owned variants for queueing / cross-thread handoff --------------
147//
148// `name` and `key` are `String` (not `&'static str`) so the type is
149// serde-friendly and round-trips through redb / JSON. The `&'static`
150// guarantee is load-bearing only on `Event<'_>` (the in-flight type
151// where codegen ensures literal-only usage); once an event is owned
152// for queueing, the static-ness has already done its job.
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct OwnedEvent {
156    pub name: String,
157    pub category: EventCategory,
158    pub timestamp: SystemTime,
159    pub install_id: Option<String>,
160    pub session_id: String,
161    pub schema_version: u32,
162    pub props: Vec<OwnedProp>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct OwnedProp {
167    pub key: String,
168    pub value: OwnedPropValue,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub enum OwnedPropValue {
173    Str(String),
174    U32(u32),
175    I64(i64),
176    F64Bucket(F64Bucket),
177    Bool(bool),
178    HistogramStrU32(Vec<(String, u32)>),
179}
180
181// --- IntentSource ---------------------------------------------------
182
183/// Where an intent came from. Reserved for future use; all dispatch
184/// sites currently emit `IntentSource::Unknown` because the dispatch
185/// path doesn't yet propagate origin information. Plumbing through the
186/// real source is not yet implemented.
187#[derive(Copy, Clone, Debug, PartialEq, Eq)]
188pub enum IntentSource {
189    Shortcut,
190    Menu,
191    Handler,
192    Programmatic,
193    Accessibility,
194    Unknown,
195}
196
197impl IntentSource {
198    pub fn as_str(self) -> &'static str {
199        match self {
200            Self::Shortcut => "shortcut",
201            Self::Menu => "menu",
202            Self::Handler => "handler",
203            Self::Programmatic => "programmatic",
204            Self::Accessibility => "accessibility",
205            Self::Unknown => "unknown",
206        }
207    }
208}
209
210// --- RemoteDataExport (Art. 15 + 20) -------------------------------
211
212/// Server-side data fetched by `UsageReporter::fetch_remote_data`.
213///
214/// Self-describing: the `schema_version`, `endpoint`, and `adapter`
215/// fields make the exported document a complete RGPD Art. 20
216/// portability artifact when serialized to JSON. The widget's
217/// "Save as JSON…" button writes this struct verbatim.
218///
219/// `Serialize` is derived; `Deserialize` is not (the `adapter` field
220/// is `&'static str`). The export is a write-only artifact.
221#[derive(Debug, Clone, Serialize)]
222pub struct RemoteDataExport {
223    pub install_id: String,
224    pub fetched_at: SystemTime,
225    pub adapter: &'static str,
226    pub endpoint: String,
227    pub schema_version: u32,
228    pub events: Vec<RemoteEvent>,
229    pub server_metadata: BTreeMap<String, RemoteValue>,
230}
231
232#[derive(Debug, Clone, Serialize)]
233pub struct RemoteEvent {
234    pub name: String,
235    pub timestamp: SystemTime,
236    pub properties: BTreeMap<String, RemoteValue>,
237}
238
239/// JSON-shaped value type for fetched server records. Kept minimal
240/// (no serde dep at the `teksilo-core` level); adapter crates may map
241/// to their own richer types as needed.
242#[derive(Debug, Clone, PartialEq, Serialize)]
243pub enum RemoteValue {
244    String(String),
245    Int(i64),
246    Float(f64),
247    Bool(bool),
248    Null,
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn event_to_owned_round_trip() {
257        let props = [
258            Prop {
259                key: "name",
260                value: PropValue::StaticStr("app.save"),
261            },
262            Prop {
263                key: "count",
264                value: PropValue::U32(3),
265            },
266        ];
267        let event = Event {
268            name: "intent.dispatched",
269            category: EventCategory::Intent,
270            timestamp: SystemTime::UNIX_EPOCH,
271            install_id: None,
272            session_id: "abc",
273            schema_version: 1,
274            props: &props,
275        };
276        let owned = event.to_owned();
277        assert_eq!(owned.name, "intent.dispatched");
278        assert_eq!(owned.props.len(), 2);
279        assert!(matches!(owned.props[0].value, OwnedPropValue::Str(ref s) if s == "app.save"));
280        assert!(matches!(owned.props[1].value, OwnedPropValue::U32(3)));
281    }
282
283    #[test]
284    fn intent_source_str() {
285        assert_eq!(IntentSource::Shortcut.as_str(), "shortcut");
286        assert_eq!(IntentSource::Unknown.as_str(), "unknown");
287    }
288}