1use serde::de::DeserializeOwned;
66use serde::{Deserialize, Serialize};
67use serde_json::Value;
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct RawEvent {
71 #[serde(rename = "type")]
72 pub event_type: String,
73 pub ts_ms: i64,
74 pub data: Value,
75}
76
77pub const TYPE_METRICS_ROLLUP: &str = "mesh_metrics_rollup";
84pub const TYPE_SESSION_SUMMARY: &str = "mesh_session_summary";
85pub const TYPE_MESSAGE_DELIVERED: &str = "protocol.message.delivered";
86pub const TYPE_MESSAGE_FAILED: &str = "protocol.message.failed";
87pub const TYPE_MESSAGE_DEFERRED: &str = "protocol.message.deferred";
88pub const TYPE_MESSAGE_RELAYED: &str = "protocol.message.relayed";
89pub const TYPE_RELAY_PROMOTED: &str = "protocol.relay.promoted";
90pub const TYPE_RELAY_DEMOTED: &str = "protocol.relay.demoted";
91pub const TYPE_RELAY_DEMOTED_BATTERY: &str = "protocol.relay.demoted_battery";
92pub const TYPE_ROUTING_DECISION: &str = "protocol.routing.decision";
93pub const TYPE_TRANSPORT_STATE: &str = "protocol.transport.state_changed";
94pub const TYPE_DEVICE_CAPABILITY: &str = "protocol.device.capability_changed";
95pub const TYPE_MLS_DECRYPTION_FAILED: &str = "mls.decryption_failed";
96pub const TYPE_MLS_SESSION_MISSING: &str = "mls.session_missing";
97
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
105pub struct TransportTimeMs {
106 pub ble: Option<i32>,
107 pub wifi_direct: Option<i32>,
108 pub internet: Option<i32>,
109 pub none: Option<i32>,
110}
111
112#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct EscalationReasons {
116 pub poor_signal: Option<i32>,
117 pub low_success_rate: Option<i32>,
118 pub retry_threshold: Option<i32>,
119 pub congestion: Option<i32>,
120 pub low_ttl: Option<i32>,
121 pub current_unavailable: Option<i32>,
122}
123
124#[derive(Debug, Clone, Default, Serialize, Deserialize)]
132pub struct MetricsRollupData {
133 pub bucket_duration_s: Option<i16>,
134 pub neighbor_count_avg: Option<f32>,
135 pub neighbor_count_max: Option<i32>,
136 pub neighbor_count_p50: Option<i32>,
137 pub ack_pending_avg: Option<f32>,
138 pub ack_pending_max: Option<i32>,
139 pub retry_queue_total_avg: Option<f32>,
140 pub retry_queue_total_max: Option<i32>,
141 #[serde(rename = "retry_queue_critical_count_max")]
142 pub retry_queue_critical_max: Option<i32>,
143 pub transport_time_ms: Option<TransportTimeMs>,
144 pub is_local_relay_duration_s: Option<i32>,
145 pub current_transport_at_bucket_end: Option<String>,
146 #[serde(rename = "sends_attempted_sum")]
147 pub sends_attempted: Option<i32>,
148 #[serde(rename = "sends_succeeded_sum")]
149 pub sends_succeeded: Option<i32>,
150 #[serde(rename = "sends_failed_sum")]
151 pub sends_failed: Option<i32>,
152 #[serde(rename = "bytes_sent_sum")]
153 pub bytes_sent: Option<i64>,
154 #[serde(rename = "bytes_received_sum")]
155 pub bytes_received: Option<i64>,
156}
157
158#[derive(Debug, Clone, Default, Serialize, Deserialize)]
160pub struct MessageDeliveredData {
161 pub latency_ms: Option<i32>,
162 pub hop_count: Option<i16>,
163 pub transport: Option<String>,
164}
165
166#[derive(Debug, Clone, Default, Serialize, Deserialize)]
173pub struct ReasonRetryData {
174 pub reason_class: Option<String>,
175 pub retry_count: Option<i16>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub reason: Option<String>,
182}
183
184#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186pub struct MessageRelayedData {
187 pub hop_count: Option<i16>,
188 pub remaining_ttl: Option<i16>,
189}
190
191#[derive(Debug, Clone, Default, Serialize, Deserialize)]
196pub struct RelayPromotedData {
197 pub connection_count: Option<i16>,
198 pub battery_level: Option<i16>,
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
207pub struct RelayDemotedData {
208 pub reason_class: Option<String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub reason: Option<String>,
212}
213
214#[derive(Debug, Clone, Default, Serialize, Deserialize)]
219pub struct RelayDemotedBatteryData {
220 pub battery_level: Option<i16>,
221 pub min_required: Option<i16>,
222}
223
224#[derive(Debug, Clone, Default, Serialize, Deserialize)]
229pub struct RoutingDecisionData {
230 pub phase: Option<String>,
231 #[serde(rename = "from")]
232 pub from_transport: Option<String>,
233 #[serde(rename = "to")]
234 pub to_transport: Option<String>,
235 pub winning_score: Option<f32>,
236 pub reason_code: Option<String>,
237}
238
239#[derive(Debug, Clone, Default, Serialize, Deserialize)]
243pub struct TransportStateData {
244 pub transport: Option<String>,
245 #[serde(rename = "previous")]
246 pub previous_status: Option<String>,
247 #[serde(rename = "current")]
248 pub current_status: Option<String>,
249}
250
251#[derive(Debug, Clone, Default, Serialize, Deserialize)]
255pub struct DeviceCapabilityData {
256 pub battery_level: Option<i16>,
257 pub is_charging: Option<bool>,
258 pub relay_role: Option<String>,
259 pub changed_fields: Option<i16>,
260}
261
262#[derive(Debug, Clone, Default, Serialize, Deserialize)]
265pub struct MlsDecryptionFailedData {
266 pub failure_kind: Option<String>,
267 #[serde(rename = "context")]
268 pub mls_context: Option<String>,
269}
270
271#[derive(Debug, Clone, Default, Serialize, Deserialize)]
273pub struct MlsSessionMissingData {
274 #[serde(rename = "context")]
275 pub mls_context: Option<String>,
276}
277
278#[derive(Debug, Clone, Default, Serialize, Deserialize)]
304pub struct SessionSummaryData {
305 pub session_duration_s: Option<i32>,
306 pub delivery_latency_p50_ms: Option<i32>,
307 pub delivery_latency_p99_ms: Option<i32>,
308 pub messages_sent: Option<i32>,
309 pub messages_delivered: Option<i32>,
310 pub messages_failed: Option<i32>,
311 pub transport_time_ms: Option<TransportTimeMs>,
312 pub routing_switches: Option<i32>,
313 pub routing_escalations: Option<i32>,
314 pub escalation_reasons: Option<EscalationReasons>,
315 pub mls_decryption_failures_by_kind: Option<Value>,
316 pub mls_session_ready_latency_p50_ms: Option<i32>,
317 pub mls_encryption_used_count: Option<i32>,
318 pub relay_duration_s: Option<i32>,
319 pub relay_battery_at_demote: Option<Vec<i16>>,
320 pub battery_start: Option<i16>,
321 pub battery_end: Option<i16>,
322}
323
324#[derive(Debug, Clone)]
332pub enum Event {
333 MetricsRollup(MetricsRollupData),
334 MessageDelivered(MessageDeliveredData),
335 MessageFailed(ReasonRetryData),
336 MessageDeferred(ReasonRetryData),
337 MessageRelayed(MessageRelayedData),
338 RelayPromoted(RelayPromotedData),
339 RelayDemoted(RelayDemotedData),
340 RelayDemotedBattery(RelayDemotedBatteryData),
341 RoutingDecision(RoutingDecisionData),
342 TransportState(TransportStateData),
343 DeviceCapability(DeviceCapabilityData),
344 MlsDecryptionFailed(MlsDecryptionFailedData),
345 MlsSessionMissing(MlsSessionMissingData),
346 SessionSummary(SessionSummaryData),
347 Extension {
350 ext_name: String,
351 payload: Value,
352 },
353}
354
355fn parse<T: DeserializeOwned>(event_type: &str, data: &Value) -> Result<T, String> {
366 T::deserialize(data).map_err(|e| format!("{event_type}: {e}"))
367}
368
369pub fn classify(event_type: &str, data: &Value) -> Result<Event, String> {
375 Ok(match event_type {
376 TYPE_METRICS_ROLLUP => Event::MetricsRollup(parse(event_type, data)?),
377 TYPE_SESSION_SUMMARY => Event::SessionSummary(parse(event_type, data)?),
378 TYPE_MESSAGE_DELIVERED => Event::MessageDelivered(parse(event_type, data)?),
379 TYPE_MESSAGE_FAILED => Event::MessageFailed(parse(event_type, data)?),
380 TYPE_MESSAGE_DEFERRED => Event::MessageDeferred(parse(event_type, data)?),
381 TYPE_MESSAGE_RELAYED => Event::MessageRelayed(parse(event_type, data)?),
382 TYPE_RELAY_PROMOTED => Event::RelayPromoted(parse(event_type, data)?),
383 TYPE_RELAY_DEMOTED => Event::RelayDemoted(parse(event_type, data)?),
384 TYPE_RELAY_DEMOTED_BATTERY => Event::RelayDemotedBattery(parse(event_type, data)?),
385 TYPE_ROUTING_DECISION => Event::RoutingDecision(parse(event_type, data)?),
386 TYPE_TRANSPORT_STATE => Event::TransportState(parse(event_type, data)?),
387 TYPE_DEVICE_CAPABILITY => Event::DeviceCapability(parse(event_type, data)?),
388 TYPE_MLS_DECRYPTION_FAILED => Event::MlsDecryptionFailed(parse(event_type, data)?),
389 TYPE_MLS_SESSION_MISSING => Event::MlsSessionMissing(parse(event_type, data)?),
390 other => Event::Extension {
391 ext_name: other.to_string(),
392 payload: data.clone(),
393 },
394 })
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use serde_json::json;
401
402 #[test]
403 fn classifies_metrics_rollup_with_renames_and_nesting() {
404 let data = json!({
405 "bucket_start_ms": 1_747_000_000_000_i64,
406 "bucket_duration_s": 60,
407 "neighbor_count_avg": 4.5,
408 "neighbor_count_max": 9,
409 "neighbor_count_p50": 4,
410 "ack_pending_avg": 1.25,
411 "ack_pending_max": 3,
412 "retry_queue_total_avg": 2.0,
413 "retry_queue_total_max": 5,
414 "retry_queue_critical_count_max": 1,
415 "transport_time_ms": { "ble": 40000, "wifi_direct": 15000, "internet": 5000, "none": 0 },
416 "is_local_relay_duration_s": 12,
417 "current_transport_at_bucket_end": "ble",
418 "sends_attempted_sum": 100,
419 "sends_succeeded_sum": 98,
420 "sends_failed_sum": 2,
421 "bytes_sent_sum": 5_000_000_000_i64,
422 "bytes_received_sum": 6_000_000_000_i64
423 });
424 let Event::MetricsRollup(d) = classify(TYPE_METRICS_ROLLUP, &data).unwrap() else {
425 panic!("expected MetricsRollup");
426 };
427 assert_eq!(d.retry_queue_critical_max, Some(1));
428 assert_eq!(d.sends_attempted, Some(100));
429 assert_eq!(d.bytes_received, Some(6_000_000_000));
430 let t = d.transport_time_ms.unwrap();
431 assert_eq!(t.ble, Some(40000));
432 assert_eq!(t.none, Some(0));
433 assert_eq!(d.current_transport_at_bucket_end.as_deref(), Some("ble"));
434 }
435
436 #[test]
437 fn classifies_routing_decision_from_to_renames() {
438 let data = json!({
439 "phase": "select",
440 "from": "ble",
441 "to": "internet",
442 "winning_score": 0.87,
443 "reason_code": "poor_signal",
444 "scores": [0.1, 0.87] });
446 let Event::RoutingDecision(d) = classify(TYPE_ROUTING_DECISION, &data).unwrap() else {
447 panic!("expected RoutingDecision");
448 };
449 assert_eq!(d.from_transport.as_deref(), Some("ble"));
450 assert_eq!(d.to_transport.as_deref(), Some("internet"));
451 }
452
453 #[test]
454 fn classifies_mls_context_rename() {
455 let data = json!({ "context": "receive" });
456 let Event::MlsSessionMissing(d) = classify(TYPE_MLS_SESSION_MISSING, &data).unwrap() else {
457 panic!("expected MlsSessionMissing");
458 };
459 assert_eq!(d.mls_context.as_deref(), Some("receive"));
460 }
461
462 #[test]
463 fn transport_state_status_renames() {
464 let data = json!({ "transport": "ble", "previous": "available", "current": "unavailable" });
465 let Event::TransportState(d) = classify(TYPE_TRANSPORT_STATE, &data).unwrap() else {
466 panic!("expected TransportState");
467 };
468 assert_eq!(d.previous_status.as_deref(), Some("available"));
469 assert_eq!(d.current_status.as_deref(), Some("unavailable"));
470 }
471
472 #[test]
473 fn empty_data_object_is_all_none_not_rejected() {
474 let Event::MessageDelivered(d) = classify(TYPE_MESSAGE_DELIVERED, &json!({})).unwrap()
476 else {
477 panic!("expected MessageDelivered");
478 };
479 assert!(d.latency_ms.is_none() && d.hop_count.is_none() && d.transport.is_none());
480 }
481
482 #[test]
483 fn out_of_range_smallint_is_rejected() {
484 let data = json!({ "hop_count": 40000 });
487 let err = classify(TYPE_MESSAGE_DELIVERED, &data).unwrap_err();
488 assert!(
489 err.contains(TYPE_MESSAGE_DELIVERED),
490 "reason names the type: {err}"
491 );
492 }
493
494 #[test]
495 fn wrong_field_type_is_rejected() {
496 let data = json!({ "latency_ms": "fast" });
498 assert!(classify(TYPE_MESSAGE_DELIVERED, &data).is_err());
499 }
500
501 #[test]
502 fn non_object_data_for_known_type_is_rejected() {
503 assert!(classify(TYPE_MESSAGE_FAILED, &Value::Null).is_err());
504 assert!(classify(TYPE_MESSAGE_FAILED, &json!("nope")).is_err());
505 }
506
507 #[test]
508 fn unknown_type_falls_through_to_extension() {
509 let data = json!({ "anything": 1 });
510 let Event::Extension { ext_name, payload } =
511 classify("protocol.brand.new_event", &data).unwrap()
512 else {
513 panic!("expected Extension");
514 };
515 assert_eq!(ext_name, "protocol.brand.new_event");
516 assert_eq!(payload, data);
517 }
518
519 #[test]
520 fn failed_and_deferred_share_reason_retry_shape() {
521 let data = json!({ "reason_class": "retries_exhausted", "retry_count": 3 });
522 assert!(matches!(
523 classify(TYPE_MESSAGE_FAILED, &data).unwrap(),
524 Event::MessageFailed(_)
525 ));
526 assert!(matches!(
527 classify(TYPE_MESSAGE_DEFERRED, &data).unwrap(),
528 Event::MessageDeferred(_)
529 ));
530 }
531
532 #[test]
533 fn session_summary_partial_payload() {
534 let data = json!({ "session_duration_s": 312, "mls_session_ready_latency_p50_ms": 88 });
539 let Event::SessionSummary(d) = classify(TYPE_SESSION_SUMMARY, &data).unwrap() else {
540 panic!("expected SessionSummary");
541 };
542 assert_eq!(d.session_duration_s, Some(312));
543 assert_eq!(d.mls_session_ready_latency_p50_ms, Some(88));
544 assert!(d.messages_sent.is_none());
545 }
546
547 #[test]
548 fn negative_value_within_column_range_is_accepted() {
549 let data = json!({ "hop_count": -1, "latency_ms": -5 });
556 let Event::MessageDelivered(d) = classify(TYPE_MESSAGE_DELIVERED, &data).unwrap() else {
557 panic!("expected MessageDelivered");
558 };
559 assert_eq!(d.hop_count, Some(-1));
560 assert_eq!(d.latency_ms, Some(-5));
561 }
562
563 #[test]
564 fn smallint_min_and_max_round_trip_through_classify() {
565 for v in [i16::MIN, i16::MAX] {
569 let data = json!({ "hop_count": v });
570 let Event::MessageDelivered(d) = classify(TYPE_MESSAGE_DELIVERED, &data).unwrap()
571 else {
572 panic!("expected MessageDelivered for hop_count={v}");
573 };
574 assert_eq!(d.hop_count, Some(v));
575 }
576 assert!(classify(
577 TYPE_MESSAGE_DELIVERED,
578 &json!({ "hop_count": i16::MAX as i64 + 1 })
579 )
580 .is_err());
581 assert!(classify(
582 TYPE_MESSAGE_DELIVERED,
583 &json!({ "hop_count": i16::MIN as i64 - 1 })
584 )
585 .is_err());
586 }
587}