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}