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 — 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}