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. [`Intent::new`](crate::Intent::new) starts
184/// every intent at `Programmatic`, and the framework's activation paths
185/// override it before dispatch: `Shortcut` when a chord match produces the
186/// intent, `Handler` for anything raised from inside a gesture handler
187/// (button taps included), `Accessibility` for an AccessKit action, and
188/// `Menu` for a handler that wrapped itself in
189/// [`EventContext::with_intent_source`](crate::EventContext::with_intent_source).
190/// Read by the dispatch tap to fill the `source` prop on
191/// `intent.dispatched`. `Unknown` is left for callers that have no origin
192/// to report; no framework dispatch site emits it.
193#[derive(Copy, Clone, Debug, PartialEq, Eq)]
194pub enum IntentSource {
195    Shortcut,
196    Menu,
197    Handler,
198    Programmatic,
199    Accessibility,
200    Unknown,
201}
202
203impl IntentSource {
204    pub fn as_str(self) -> &'static str {
205        match self {
206            Self::Shortcut => "shortcut",
207            Self::Menu => "menu",
208            Self::Handler => "handler",
209            Self::Programmatic => "programmatic",
210            Self::Accessibility => "accessibility",
211            Self::Unknown => "unknown",
212        }
213    }
214}
215
216// --- RemoteDataExport (Art. 15 + 20) -------------------------------
217
218/// Server-side data fetched by `UsageReporter::fetch_remote_data`.
219///
220/// Self-describing: the `schema_version`, `endpoint`, and `adapter`
221/// fields make the exported document a complete RGPD Art. 20
222/// portability artifact when serialized to JSON. The widget's
223/// "Save as JSON…" button writes this struct verbatim.
224///
225/// `Serialize` is derived; `Deserialize` is not (the `adapter` field
226/// is `&'static str`). The export is a write-only artifact.
227#[derive(Debug, Clone, Serialize)]
228pub struct RemoteDataExport {
229    pub install_id: String,
230    pub fetched_at: SystemTime,
231    pub adapter: &'static str,
232    pub endpoint: String,
233    pub schema_version: u32,
234    pub events: Vec<RemoteEvent>,
235    pub server_metadata: BTreeMap<String, RemoteValue>,
236}
237
238#[derive(Debug, Clone, Serialize)]
239pub struct RemoteEvent {
240    pub name: String,
241    pub timestamp: SystemTime,
242    pub properties: BTreeMap<String, RemoteValue>,
243}
244
245/// JSON-shaped value type for fetched server records. Kept minimal
246/// (no serde dep at the `teksilo-core` level); adapter crates may map
247/// to their own richer types as needed.
248#[derive(Debug, Clone, PartialEq, Serialize)]
249pub enum RemoteValue {
250    String(String),
251    Int(i64),
252    Float(f64),
253    Bool(bool),
254    Null,
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn event_to_owned_round_trip() {
263        let props = [
264            Prop {
265                key: "name",
266                value: PropValue::StaticStr("app.save"),
267            },
268            Prop {
269                key: "count",
270                value: PropValue::U32(3),
271            },
272        ];
273        let event = Event {
274            name: "intent.dispatched",
275            category: EventCategory::Intent,
276            timestamp: SystemTime::UNIX_EPOCH,
277            install_id: None,
278            session_id: "abc",
279            schema_version: 1,
280            props: &props,
281        };
282        let owned = event.to_owned();
283        assert_eq!(owned.name, "intent.dispatched");
284        assert_eq!(owned.props.len(), 2);
285        assert!(matches!(owned.props[0].value, OwnedPropValue::Str(ref s) if s == "app.save"));
286        assert!(matches!(owned.props[1].value, OwnedPropValue::U32(3)));
287    }
288
289    #[test]
290    fn intent_source_str() {
291        assert_eq!(IntentSource::Shortcut.as_str(), "shortcut");
292        assert_eq!(IntentSource::Unknown.as_str(), "unknown");
293    }
294}