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