Skip to main content

offline_protocol/telemetry/
routing.rs

1//! Structured superset of the legacy `Event::Dors*` events.
2//!
3//! Where the `Event::DorsScoreUpdated` / `Event::DorsTransportSelected` /
4//! `Event::DorsTransportSwitched` / `Event::DorsEscalationTriggered` variants
5//! report a flattened subset (transport name + score + reason code), this
6//! record carries the full per-factor scoring breakdown that DORS evaluates
7//! before it decides which transport to use.
8//!
9//! The legacy `Event::Dors*` path continues to fire for back-compat, and it
10//! is what an application observes. This record is consumed inside the SDK
11//! by the telemetry pipe, and
12//! [`crate::telemetry::TelemetryConfig::with_routing_diagnostic`] is what
13//! populates its scores.
14
15use offline_protocol_router::TransportScore;
16use offline_protocol_transport::TransportType;
17
18/// Which kind of routing decision this record describes.
19///
20/// `#[non_exhaustive]` so new phases can be added without a breaking-change bump.
21#[non_exhaustive]
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum RoutingPhase {
24    /// DORS recomputed per-transport scores without switching.
25    ScoreUpdated,
26    /// DORS picked a transport (initial selection or post-switch).
27    Selected,
28    /// DORS changed the active transport.
29    Switched,
30    /// BLE→WiFi escalation triggered or applied.
31    Escalated,
32}
33
34/// Top-level reason space for routing decisions.
35///
36/// Unifies the legacy `DorsReasonCode` (selection/switch reasons) and
37/// `DorsEscalationReasonCode` (escalation reasons) into a single flat enum
38/// that a sink can match on without juggling multiple legacy code spaces.
39/// `#[non_exhaustive]` so new reasons can be added.
40#[non_exhaustive]
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum RoutingReasonCode {
43    /// Initial selection at protocol start.
44    InitialSelection,
45    /// Primary transport selected on merit.
46    PrimarySelected,
47    /// Primary transport's last send succeeded.
48    PrimarySuccess,
49    /// Fallback transport's last send succeeded.
50    FallbackSuccess,
51    /// Escalation fallback was applied end-to-end.
52    EscalationApplied,
53    /// Current transport became unavailable.
54    CurrentUnavailable,
55    /// Retry-failure threshold tripped escalation.
56    RetryThreshold,
57    /// Sustained poor signal tripped escalation.
58    PoorSignal,
59    /// Sustained congestion tripped escalation.
60    Congestion,
61    /// Low TTL headroom tripped escalation.
62    LowTtl,
63    /// Sustained low success rate tripped escalation.
64    LowSuccessRate,
65}
66
67/// A single structured routing decision.
68///
69/// Populated by the DORS callback installed by
70/// `OfflineProtocol::install_telemetry_sink`. The `scores` field is only
71/// populated when `TelemetryConfig::routing_diagnostic` is `true`; at the
72/// default verbosity the vector is empty, keeping the hot-path allocation
73/// free.
74///
75/// A future PR will introduce a `Suppressed` phase carrying hysteresis /
76/// cooldown / stability detail; that variant and its payload were excluded
77/// from this record on purpose so the shape can be designed alongside the
78/// emit sites that produce it.
79#[derive(Debug, Clone)]
80pub struct RoutingDecision {
81    /// Emission timestamp (milliseconds since Unix epoch).
82    pub timestamp_ms: i64,
83    /// Which kind of decision this is.
84    pub phase: RoutingPhase,
85    /// Previous transport, when applicable.
86    pub from: Option<TransportType>,
87    /// Chosen / target transport, when applicable.
88    pub to: Option<TransportType>,
89    /// Total score of the winning transport, when applicable.
90    pub winning_score: Option<f32>,
91    /// Reason for the decision, when one applies.
92    pub reason_code: Option<RoutingReasonCode>,
93    /// Full per-transport score breakdown. Empty unless
94    /// `TelemetryConfig::routing_diagnostic` is enabled.
95    pub scores: Vec<(TransportType, TransportScore)>,
96}
97
98impl RoutingDecision {
99    /// Stable OTel-friendly name for this record.
100    pub const NAME: &'static str = "protocol.routing.decision";
101
102    /// Returns the stable telemetry name.
103    pub fn telemetry_name(&self) -> &'static str {
104        Self::NAME
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn name_is_stable() {
114        let decision = RoutingDecision {
115            timestamp_ms: 0,
116            phase: RoutingPhase::Selected,
117            from: None,
118            to: Some(TransportType::BLE),
119            winning_score: Some(84.0),
120            reason_code: Some(RoutingReasonCode::InitialSelection),
121            scores: Vec::new(),
122        };
123        assert_eq!(RoutingDecision::NAME, "protocol.routing.decision");
124        assert_eq!(decision.telemetry_name(), "protocol.routing.decision");
125    }
126}