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}