Skip to main content

offline_protocol_telemetry_wire/
batch.rs

1//! Top-level batch envelope posted by the analytics client to `POST /v1/events`.
2
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use crate::event::RawEvent;
7
8/// Bumped on incompatible wire-schema changes. Servers must accept the
9/// current version and (per plan §6.4) the previous version for at least
10/// 90 days after the bump.
11pub type WireVersion = u8;
12
13pub const CURRENT_WIRE_VERSION: WireVersion = 1;
14
15/// Idempotency key — the server dedups on this. Replays return 200
16/// `{"deduplicated": true}` and write nothing.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(transparent)]
19pub struct BatchId(pub Uuid);
20
21/// Minted by the telemetry client when telemetry is enabled, and rotated at
22/// the first foreground after a background or at an explicit session end.
23/// The protocol emits no session id of its own, so the client owns this one.
24///
25/// The server hashes this into `session_hash` with a quarterly-rotating
26/// secret before any row is stored.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(transparent)]
29pub struct SessionId(pub Uuid);
30
31/// Stable per-install identifier, present only when the underlying mesh SDK
32/// exposes its telemetry install id (derived from the per-install scrub
33/// secret; an already-scrubbed opaque string, never a raw device serial).
34/// Absent on SDKs without the capability — the server then falls back to
35/// session-unit counting (plan §1.1 distinct-device migration path).
36///
37/// The server hashes this into `device_hash` with the same quarterly-rotating
38/// secret as `session_id` (domain-separated) before any row is stored.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(transparent)]
41pub struct DeviceId(pub String);
42
43/// The host operating system a batch was produced on.
44///
45/// `ios` and `android` are the two values wire v1 shipped with. The desktop
46/// values are additive: the SDK's Python binding runs on all three, and a
47/// host that cannot name itself has nowhere honest to go but `other`. An
48/// ingest that predates them rejects a desktop batch with 422, which is the
49/// documented release gate for widening this enum, not a client bug.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "lowercase")]
52pub enum OsKind {
53    /// Apple iOS.
54    Ios,
55    /// Android.
56    Android,
57    /// A Linux desktop or server host.
58    Linux,
59    /// Apple macOS.
60    Macos,
61    /// Microsoft Windows.
62    Windows,
63    /// A host the SDK could not classify.
64    Other,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Batch {
69    pub batch_id: BatchId,
70    pub session_id: SessionId,
71    /// Optional — added after wire v1 shipped. Old clients omit it (serde
72    /// defaults to `None`); old servers ignore it (no `deny_unknown_fields`).
73    /// No `wire_version` bump needed.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub device_id: Option<DeviceId>,
76    pub wire_version: WireVersion,
77    pub app_version: String,
78    pub os: OsKind,
79    pub os_major: u16,
80    pub sdk_version: String,
81    pub events: Vec<RawEvent>,
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use serde_json::json;
88
89    #[test]
90    fn batch_round_trip_preserves_fields() {
91        let batch = Batch {
92            batch_id: BatchId(Uuid::new_v4()),
93            session_id: SessionId(Uuid::new_v4()),
94            device_id: None,
95            wire_version: CURRENT_WIRE_VERSION,
96            app_version: "1.4.2".into(),
97            os: OsKind::Ios,
98            os_major: 18,
99            sdk_version: "0.11.0".into(),
100            events: vec![RawEvent {
101                event_type: "protocol.message.delivered".into(),
102                ts_ms: 1_747_000_000_000,
103                data: json!({ "latency_ms": 240, "hop_count": 1, "transport": "ble" }),
104            }],
105        };
106
107        let s = serde_json::to_string(&batch).unwrap();
108        let back: Batch = serde_json::from_str(&s).unwrap();
109
110        assert_eq!(back.events.len(), 1);
111        assert_eq!(back.events[0].event_type, "protocol.message.delivered");
112        assert_eq!(back.wire_version, CURRENT_WIRE_VERSION);
113        assert!(matches!(back.os, OsKind::Ios));
114    }
115
116    #[test]
117    fn os_lowercases_in_wire_form() {
118        let v = serde_json::to_value(OsKind::Android).unwrap();
119        assert_eq!(v, json!("android"));
120    }
121
122    #[test]
123    fn pre_device_id_wire_v1_json_still_deserializes() {
124        // A batch as serialized by clients shipped before device_id existed.
125        let legacy = json!({
126            "batch_id": "1f0e9a4c-3b6d-4e2f-9a1b-2c3d4e5f6a7b",
127            "session_id": "00000000-0000-0000-0000-000000000001",
128            "wire_version": 1,
129            "app_version": "1.4.2",
130            "os": "android",
131            "os_major": 15,
132            "sdk_version": "0.11.0",
133            "events": []
134        });
135        let batch: Batch = serde_json::from_value(legacy).unwrap();
136        assert_eq!(batch.device_id, None);
137    }
138
139    #[test]
140    fn device_id_round_trips_and_is_omitted_when_absent() {
141        let mut batch = Batch {
142            batch_id: BatchId(Uuid::new_v4()),
143            session_id: SessionId(Uuid::new_v4()),
144            device_id: None,
145            wire_version: CURRENT_WIRE_VERSION,
146            app_version: "1.4.2".into(),
147            os: OsKind::Android,
148            os_major: 15,
149            sdk_version: "0.11.0".into(),
150            events: vec![],
151        };
152
153        let v = serde_json::to_value(&batch).unwrap();
154        assert!(v.get("device_id").is_none());
155
156        batch.device_id = Some(DeviceId("a3f2b4c5d6e7f8091a2b3c4d5e6f7a8b".into()));
157        let v = serde_json::to_value(&batch).unwrap();
158        assert_eq!(v["device_id"], json!("a3f2b4c5d6e7f8091a2b3c4d5e6f7a8b"));
159        let back: Batch = serde_json::from_value(v).unwrap();
160        assert_eq!(back.device_id, batch.device_id);
161    }
162}