offline-protocol-telemetry-wire 0.26.0

Wire types for the Offline Protocol telemetry ingest: the batch envelope and its events
Documentation
//! Top-level batch envelope posted by the analytics client to `POST /v1/events`.

use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::event::RawEvent;

/// Bumped on incompatible wire-schema changes. Servers must accept the
/// current version and (per plan §6.4) the previous version for at least
/// 90 days after the bump.
pub type WireVersion = u8;

pub const CURRENT_WIRE_VERSION: WireVersion = 1;

/// Idempotency key — the server dedups on this. Replays return 200
/// `{"deduplicated": true}` and write nothing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct BatchId(pub Uuid);

/// Minted by the telemetry client when telemetry is enabled, and rotated at
/// the first foreground after a background or at an explicit session end.
/// The protocol emits no session id of its own, so the client owns this one.
///
/// The server hashes this into `session_hash` with a quarterly-rotating
/// secret before any row is stored.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionId(pub Uuid);

/// Stable per-install identifier, present only when the underlying mesh SDK
/// exposes its telemetry install id (derived from the per-install scrub
/// secret; an already-scrubbed opaque string, never a raw device serial).
/// Absent on SDKs without the capability — the server then falls back to
/// session-unit counting (plan §1.1 distinct-device migration path).
///
/// The server hashes this into `device_hash` with the same quarterly-rotating
/// secret as `session_id` (domain-separated) before any row is stored.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct DeviceId(pub String);

/// The host operating system a batch was produced on.
///
/// `ios` and `android` are the two values wire v1 shipped with. The desktop
/// values are additive: the SDK's Python binding runs on all three, and a
/// host that cannot name itself has nowhere honest to go but `other`. An
/// ingest that predates them rejects a desktop batch with 422, which is the
/// documented release gate for widening this enum, not a client bug.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OsKind {
    /// Apple iOS.
    Ios,
    /// Android.
    Android,
    /// A Linux desktop or server host.
    Linux,
    /// Apple macOS.
    Macos,
    /// Microsoft Windows.
    Windows,
    /// A host the SDK could not classify.
    Other,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Batch {
    pub batch_id: BatchId,
    pub session_id: SessionId,
    /// Optional — added after wire v1 shipped. Old clients omit it (serde
    /// defaults to `None`); old servers ignore it (no `deny_unknown_fields`).
    /// No `wire_version` bump needed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub device_id: Option<DeviceId>,
    pub wire_version: WireVersion,
    pub app_version: String,
    pub os: OsKind,
    pub os_major: u16,
    pub sdk_version: String,
    pub events: Vec<RawEvent>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn batch_round_trip_preserves_fields() {
        let batch = Batch {
            batch_id: BatchId(Uuid::new_v4()),
            session_id: SessionId(Uuid::new_v4()),
            device_id: None,
            wire_version: CURRENT_WIRE_VERSION,
            app_version: "1.4.2".into(),
            os: OsKind::Ios,
            os_major: 18,
            sdk_version: "0.11.0".into(),
            events: vec![RawEvent {
                event_type: "protocol.message.delivered".into(),
                ts_ms: 1_747_000_000_000,
                data: json!({ "latency_ms": 240, "hop_count": 1, "transport": "ble" }),
            }],
        };

        let s = serde_json::to_string(&batch).unwrap();
        let back: Batch = serde_json::from_str(&s).unwrap();

        assert_eq!(back.events.len(), 1);
        assert_eq!(back.events[0].event_type, "protocol.message.delivered");
        assert_eq!(back.wire_version, CURRENT_WIRE_VERSION);
        assert!(matches!(back.os, OsKind::Ios));
    }

    #[test]
    fn os_lowercases_in_wire_form() {
        let v = serde_json::to_value(OsKind::Android).unwrap();
        assert_eq!(v, json!("android"));
    }

    #[test]
    fn pre_device_id_wire_v1_json_still_deserializes() {
        // A batch as serialized by clients shipped before device_id existed.
        let legacy = json!({
            "batch_id": "1f0e9a4c-3b6d-4e2f-9a1b-2c3d4e5f6a7b",
            "session_id": "00000000-0000-0000-0000-000000000001",
            "wire_version": 1,
            "app_version": "1.4.2",
            "os": "android",
            "os_major": 15,
            "sdk_version": "0.11.0",
            "events": []
        });
        let batch: Batch = serde_json::from_value(legacy).unwrap();
        assert_eq!(batch.device_id, None);
    }

    #[test]
    fn device_id_round_trips_and_is_omitted_when_absent() {
        let mut batch = Batch {
            batch_id: BatchId(Uuid::new_v4()),
            session_id: SessionId(Uuid::new_v4()),
            device_id: None,
            wire_version: CURRENT_WIRE_VERSION,
            app_version: "1.4.2".into(),
            os: OsKind::Android,
            os_major: 15,
            sdk_version: "0.11.0".into(),
            events: vec![],
        };

        let v = serde_json::to_value(&batch).unwrap();
        assert!(v.get("device_id").is_none());

        batch.device_id = Some(DeviceId("a3f2b4c5d6e7f8091a2b3c4d5e6f7a8b".into()));
        let v = serde_json::to_value(&batch).unwrap();
        assert_eq!(v["device_id"], json!("a3f2b4c5d6e7f8091a2b3c4d5e6f7a8b"));
        let back: Batch = serde_json::from_value(v).unwrap();
        assert_eq!(back.device_id, batch.device_id);
    }
}