1use crate::events::Event;
24use crate::mls_observability::MlsLifecycleEvent;
25use crate::telemetry::device::DeviceCapabilitySnapshot;
26use crate::telemetry::metrics_snapshot::MetricsFrame;
27use crate::telemetry::routing::RoutingDecision;
28use crate::telemetry::transport_state::TransportStateEvent;
29
30#[non_exhaustive]
35#[derive(Debug, Clone)]
36pub enum TelemetryRecord {
37 Protocol(Box<Event>),
44 Mls(MlsLifecycleEvent),
46 MetricsSnapshot(Box<MetricsFrame>),
51 TransportState(TransportStateEvent),
54 Routing(Box<RoutingDecision>),
59 Device(DeviceCapabilitySnapshot),
61}
62
63impl TelemetryRecord {
64 pub fn name(&self) -> &'static str {
67 match self {
68 TelemetryRecord::Protocol(event) => event.telemetry_name(),
69 TelemetryRecord::Mls(event) => event.telemetry_name(),
70 TelemetryRecord::MetricsSnapshot(frame) => frame.telemetry_name(),
71 TelemetryRecord::TransportState(event) => event.telemetry_name(),
72 TelemetryRecord::Routing(decision) => decision.telemetry_name(),
73 TelemetryRecord::Device(snapshot) => snapshot.telemetry_name(),
74 }
75 }
76}
77
78#[cfg(test)]
79pub(crate) mod tests {
80 use super::*;
81 use crate::events::{
82 DorsEscalationPhase, DorsEscalationReasonCode, DorsReasonCode, PresenceSource,
83 PresenceStatus, SecurityWarningCode, WelcomeReasonCode,
84 };
85 use crate::mls_observability::{DecryptionFailureKind, MlsOperationContext};
86 use std::collections::{HashMap, HashSet};
87
88 pub(crate) const ALL_TELEMETRY_NAMES: &[&str] = &[
109 "protocol.message.sent",
111 "protocol.message.received",
112 "protocol.message.delivered",
113 "protocol.message.failed",
114 "protocol.message.decryption_failed",
115 "protocol.transport.switched",
116 "protocol.relay.promoted",
117 "protocol.relay.demoted",
118 "protocol.neighbor.discovered",
119 "protocol.neighbor.lost",
120 "protocol.neighbor.rssi",
121 "protocol.identity.ready",
122 "protocol.network.metrics",
123 "protocol.file.progress",
124 "protocol.file.received",
125 "protocol.file.receive_failed",
126 "protocol.media.sent",
127 "protocol.media.send_failed",
128 "protocol.message.deferred",
129 "protocol.message.retrying",
130 "protocol.message.undeliverable",
131 "protocol.media.resend_required",
132 "protocol.ack.evicted",
133 "protocol.fragment.assembly_evicted",
134 "protocol.relay.demoted_battery",
135 "protocol.secure_session.established",
136 "protocol.secure_session.failed",
137 "protocol.convergence.diag",
138 "protocol.welcome.send_attempted",
139 "protocol.welcome.send_succeeded",
140 "protocol.welcome.send_failed",
141 "protocol.welcome.send_expired",
142 "protocol.connection.request_received",
143 "protocol.connection.request_undeliverable",
144 "protocol.connection.accepted",
145 "protocol.connection.rejected",
146 "protocol.connection.request_cancelled",
147 "protocol.group.created",
148 "protocol.group.message_received",
149 "protocol.group.member_added",
150 "protocol.group.member_removed",
151 "protocol.group.info",
152 "protocol.group.user_groups",
153 "protocol.group.error",
154 "protocol.group.relay_sync_changed",
155 "protocol.username.resolved",
156 "protocol.group.message_sent",
157 "protocol.group.message_partial_failure",
158 "protocol.group.delivery_report",
159 "protocol.group.rich_extras_dropped",
160 "protocol.group.unauthorized_membership_change",
161 "protocol.group.epoch_fork_detected",
162 "protocol.group.epoch_fork_resolved",
163 "protocol.group.role_changed",
164 "protocol.group.renamed",
165 "protocol.service.discovered",
166 "protocol.service.request_received",
167 "protocol.service.response_received",
168 "protocol.presence.updated",
169 "protocol.typing.received",
170 "protocol.read_receipt.received",
171 "protocol.dors.score_updated",
172 "protocol.dors.transport_selected",
173 "protocol.dors.transport_switched",
174 "protocol.dors.escalation_triggered",
175 "protocol.security.warning",
176 "protocol.message.relayed",
177 "protocol.user.blocked",
178 "protocol.user.unblocked",
179 "protocol.data.changed",
180 "protocol.data.doc_removed",
181 "protocol.data.doc_size_warning",
182 "protocol.data.attachment_requested",
183 "protocol.data.attachment_received",
184 "protocol.data.attachment_unavailable",
185 "protocol.data.doc_unsyncable",
186 "mls.initialized",
188 "mls.encryption_used",
189 "mls.decryption_failed",
190 "mls.session_missing",
191 "mls.session_establishing",
192 "mls.session_ready",
193 "protocol.metrics.snapshot",
202 "protocol.transport.state_changed",
203 "protocol.routing.decision",
204 "protocol.device.capability_changed",
205 ];
206
207 fn is_well_formed(name: &str) -> bool {
210 if name.is_empty() {
211 return false;
212 }
213 let mut segments = name.split('.');
214 let mut segment_count = 0;
215 for segment in &mut segments {
216 if segment.is_empty() {
217 return false;
218 }
219 if !segment
220 .chars()
221 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
222 {
223 return false;
224 }
225 segment_count += 1;
226 }
227 segment_count >= 2
228 }
229
230 #[test]
237 fn telemetry_record_is_smaller_than_unboxed_event() {
238 use std::mem::size_of;
239 assert!(
240 size_of::<TelemetryRecord>() < size_of::<Event>(),
241 "TelemetryRecord ({} bytes) is not smaller than Event ({} bytes) \
242 — the `Box<Event>` variant was removed or the size budget was \
243 blown by another variant. Re-box the Protocol variant or audit \
244 the new variant's payload.",
245 size_of::<TelemetryRecord>(),
246 size_of::<Event>(),
247 );
248 }
249
250 #[test]
251 fn protocol_variant_names_are_dotted() {
252 let event = Event::MessageReceived {
253 message_id: "m".into(),
254 sender: "s".into(),
255 recipient: "r".into(),
256 content: String::new(),
257 reply_context: None,
258 hop_count: 0,
259 transport: "ble".into(),
260 timestamp: 0,
261 lamport_clock: 0,
262 reply_to_msg: None,
263 content_type: String::new(),
264 media_metadata: None,
265 forward_info: None,
266 encrypted: false,
267 app_id: String::new(),
268 };
269 assert_eq!(
270 TelemetryRecord::Protocol(Box::new(event)).name(),
271 "protocol.message.received",
272 );
273 }
274
275 #[test]
276 fn mls_variant_names_are_dotted() {
277 let event = MlsLifecycleEvent::Initialized {
278 timestamp_ms: 0,
279 session_id: "s".into(),
280 group_id: None,
281 peer_id: None,
282 context: MlsOperationContext::Initialize,
283 error_category: None,
284 };
285 assert_eq!(TelemetryRecord::Mls(event).name(), "mls.initialized");
286 }
287
288 #[allow(dead_code)]
297 fn event_variant_exhaustiveness_ward(e: &Event) {
298 match e {
299 Event::MessageSent { .. }
300 | Event::MessageReceived { .. }
301 | Event::MessageDelivered { .. }
302 | Event::MessageFailed { .. }
303 | Event::MessageDecryptionFailed { .. }
304 | Event::TransportSwitched { .. }
305 | Event::RelayPromoted { .. }
306 | Event::RelayDemoted { .. }
307 | Event::NeighborDiscovered { .. }
308 | Event::NeighborLost { .. }
309 | Event::PeerRssi { .. }
310 | Event::IdentityReady { .. }
311 | Event::NetworkMetrics { .. }
312 | Event::FileProgress { .. }
313 | Event::FileReceived { .. }
314 | Event::FileReceiveFailed { .. }
315 | Event::MediaSent { .. }
316 | Event::MediaSendFailed { .. }
317 | Event::MessageDeferred { .. }
318 | Event::MessageRetrying { .. }
319 | Event::MessageUndeliverable { .. }
320 | Event::MediaResendRequired { .. }
321 | Event::AckEvicted { .. }
322 | Event::FragmentAssemblyEvicted { .. }
323 | Event::RelayDemotedBattery { .. }
324 | Event::SecureSessionEstablished { .. }
325 | Event::SecureSessionFailed { .. }
326 | Event::ConvergenceDiag { .. }
327 | Event::WelcomeSendAttempted { .. }
328 | Event::WelcomeSendSucceeded { .. }
329 | Event::WelcomeSendFailed { .. }
330 | Event::WelcomeSendExpired { .. }
331 | Event::ConnectionRequestReceived { .. }
332 | Event::ConnectionRequestUndeliverable { .. }
333 | Event::ConnectionAccepted { .. }
334 | Event::ConnectionRejected { .. }
335 | Event::ConnectionRequestCancelled { .. }
336 | Event::GroupCreated { .. }
337 | Event::GroupMessageReceived { .. }
338 | Event::GroupMemberAdded { .. }
339 | Event::GroupMemberRemoved { .. }
340 | Event::GroupInfo { .. }
341 | Event::UserGroups { .. }
342 | Event::GroupError { .. }
343 | Event::GroupRelaySyncChanged { .. }
344 | Event::UsernameResolved { .. }
345 | Event::GroupMessageSent { .. }
346 | Event::GroupMessagePartialFailure { .. }
347 | Event::GroupMessageDeliveryReport { .. }
348 | Event::GroupRichExtrasDropped { .. }
349 | Event::GroupUnauthorizedMembershipChange { .. }
350 | Event::GroupEpochForkDetected { .. }
351 | Event::GroupEpochForkResolved { .. }
352 | Event::GroupRoleChanged { .. }
353 | Event::GroupRenamed { .. }
354 | Event::ServiceDiscovered { .. }
355 | Event::ServiceRequestReceived { .. }
356 | Event::ServiceResponseReceived { .. }
357 | Event::PresenceUpdated { .. }
358 | Event::TypingIndicatorReceived { .. }
359 | Event::ReadReceiptReceived { .. }
360 | Event::DorsScoreUpdated { .. }
361 | Event::DorsTransportSelected { .. }
362 | Event::DorsTransportSwitched { .. }
363 | Event::DorsEscalationTriggered { .. }
364 | Event::SecurityWarning { .. }
365 | Event::MessageRelayed { .. }
366 | Event::UserBlocked { .. }
367 | Event::UserUnblocked { .. }
368 | Event::DataChanged { .. }
369 | Event::DataDocRemoved { .. }
370 | Event::DataDocSizeWarning { .. }
371 | Event::DataAttachmentRequested { .. }
372 | Event::DataAttachmentReceived { .. }
373 | Event::DataAttachmentUnavailable { .. }
374 | Event::DataDocUnsyncable { .. } => (),
375 }
376 }
377
378 #[allow(dead_code)]
381 fn mls_lifecycle_exhaustiveness_ward(e: &MlsLifecycleEvent) {
382 match e {
383 MlsLifecycleEvent::Initialized { .. }
384 | MlsLifecycleEvent::EncryptionUsed { .. }
385 | MlsLifecycleEvent::DecryptionFailed { .. }
386 | MlsLifecycleEvent::SessionMissing { .. }
387 | MlsLifecycleEvent::SessionEstablishing { .. }
388 | MlsLifecycleEvent::SessionReady { .. } => (),
389 }
390 }
391
392 pub(crate) fn event_exemplars() -> Vec<Event> {
396 vec![
397 Event::MessageSent {
398 message_id: String::new(),
399 sender: String::new(),
400 recipient: String::new(),
401 content: String::new(),
402 priority: String::new(),
403 requires_ack: true,
406 timestamp: 0,
407 lamport_clock: 0,
408 forward_info: None,
409 },
410 Event::MessageReceived {
411 message_id: String::new(),
412 sender: String::new(),
413 recipient: String::new(),
414 content: String::new(),
415 hop_count: 0,
416 transport: String::new(),
417 timestamp: 0,
418 lamport_clock: 0,
419 reply_to_msg: None,
420 reply_context: None,
421 content_type: String::new(),
422 media_metadata: None,
423 forward_info: None,
424 encrypted: false,
425 app_id: String::new(),
426 },
427 Event::MessageDelivered {
428 message_id: String::new(),
429 latency_ms: 0,
430 hop_count: 0,
431 transport: String::new(),
432 },
433 Event::MessageFailed {
434 message_id: String::new(),
435 reason: String::new(),
436 retry_count: 0,
437 },
438 Event::MessageDecryptionFailed {
439 message_id: String::new(),
440 sender: String::new(),
441 code: crate::events::DecryptionFailureCode::Unknown,
442 reason: String::new(),
443 sealed_at_ms: None,
444 session_established_at_ms: None,
445 },
446 Event::TransportSwitched {
447 from: None,
448 to: String::new(),
449 reason: String::new(),
450 },
451 Event::RelayPromoted {
452 connection_count: 0,
453 battery_level: None,
454 },
455 Event::RelayDemoted {
456 reason: String::new(),
457 },
458 Event::NeighborDiscovered {
459 peer_id: String::new(),
460 transport: String::new(),
461 rssi: None,
462 },
463 Event::NeighborLost {
464 peer_id: String::new(),
465 },
466 Event::PeerRssi {
467 peer_id: String::new(),
468 transport: String::new(),
469 rssi: 0,
470 },
471 Event::IdentityReady {
472 address: String::new(),
473 },
474 Event::NetworkMetrics {
475 neighbor_count: 0,
476 relay_count: 0,
477 delivery_ratio: 0.0,
478 avg_latency_ms: 0,
479 },
480 Event::FileProgress {
481 file_id: String::new(),
482 chunks_sent: 0,
483 total_chunks: 0,
484 percentage: 0,
485 },
486 Event::FileReceived {
487 file_id: String::new(),
488 file_name: String::new(),
489 file_size: 0,
490 sender: String::new(),
491 content_type: String::new(),
492 media_metadata: None,
493 file_data: String::new(),
494 timestamp: None,
495 caption: None,
496 reply_to_msg: None,
497 reply_context: None,
498 forward_info: None,
499 app_id: None,
500 },
501 Event::FileReceiveFailed {
502 file_id: String::new(),
503 file_name: String::new(),
504 sender: String::new(),
505 reason: String::new(),
506 },
507 Event::MediaSent {
508 file_id: String::new(),
509 content_type: String::new(),
510 recipient: String::new(),
511 },
512 Event::MediaSendFailed {
513 file_id: String::new(),
514 recipient: String::new(),
515 reason: String::new(),
516 },
517 Event::MessageDeferred {
518 message_id: String::new(),
519 recipient: String::new(),
520 reason: String::new(),
521 retry_count: 0,
522 next_retry_at: None,
523 },
524 Event::MessageRetrying {
525 message_id: String::new(),
526 recipient: String::new(),
527 retry_count: 0,
528 next_retry_at: 0,
529 },
530 Event::MessageUndeliverable {
531 message_id: String::new(),
532 recipient: String::new(),
533 reason: String::new(),
534 file_id: None,
535 },
536 Event::MediaResendRequired {
537 file_id: String::new(),
538 recipient: String::new(),
539 file_name: String::new(),
540 file_size: 0,
541 app_id: None,
542 },
543 Event::AckEvicted {
544 message_id: String::new(),
545 priority: String::new(),
546 reason: String::new(),
547 },
548 Event::FragmentAssemblyEvicted {
549 message_id: String::new(),
550 completion_percent: 0,
551 reason: String::new(),
552 },
553 Event::RelayDemotedBattery {
554 battery_level: 0,
555 min_required: 0,
556 },
557 Event::SecureSessionEstablished {
558 peer_id: String::new(),
559 group_id: String::new(),
560 is_session: false,
561 initiated_by_local: false,
562 },
563 Event::SecureSessionFailed {
564 peer_id: String::new(),
565 reason: String::new(),
566 },
567 Event::ConvergenceDiag {
568 stage: String::new(),
569 peer_id: String::new(),
570 detail: String::new(),
571 },
572 Event::WelcomeSendAttempted {
573 peer_id: String::new(),
574 message_id: String::new(),
575 group_id: String::new(),
576 attempt: 0,
577 },
578 Event::WelcomeSendSucceeded {
579 peer_id: String::new(),
580 message_id: String::new(),
581 group_id: String::new(),
582 attempt: 0,
583 },
584 Event::WelcomeSendFailed {
585 peer_id: String::new(),
586 message_id: String::new(),
587 group_id: String::new(),
588 attempt: 0,
589 reason_code: WelcomeReasonCode::InternalError,
590 transport_error: None,
591 retryable: false,
592 next_retry_at: None,
593 },
594 Event::WelcomeSendExpired {
595 peer_id: String::new(),
596 message_id: String::new(),
597 attempt: 0,
598 reason_code: WelcomeReasonCode::RetryExhausted,
599 },
600 Event::ConnectionRequestReceived {
601 sender: String::new(),
602 sender_name: String::new(),
603 timestamp: 0,
604 key_package: None,
605 initial_message: None,
606 },
607 Event::ConnectionRequestUndeliverable {
608 recipient: String::new(),
609 message_id: String::new(),
610 reason: String::new(),
611 },
612 Event::ConnectionAccepted {
613 accepted_by: String::new(),
614 accepted_by_name: String::new(),
615 timestamp: 0,
616 key_package: None,
617 },
618 Event::ConnectionRejected {
619 rejected_by: String::new(),
620 },
621 Event::ConnectionRequestCancelled {
622 cancelled_by: String::new(),
623 },
624 Event::GroupCreated {
625 group_id: String::new(),
626 name: String::new(),
627 },
628 Event::GroupMessageReceived {
629 group_id: String::new(),
630 sender: String::new(),
631 content: String::new(),
632 timestamp: String::new(),
633 message_id: String::new(),
634 reply_to_msg: None,
635 forward_info: None,
636 media_metadata: None,
637 content_type: None,
638 },
639 Event::GroupMemberAdded {
640 group_id: String::new(),
641 user_id: String::new(),
642 added_by: String::new(),
643 group_name: None,
644 authorized: Some(true),
645 },
646 Event::GroupMemberRemoved {
647 group_id: String::new(),
648 user_id: String::new(),
649 removed_by: String::new(),
650 authorized: Some(true),
651 },
652 Event::GroupInfo {
653 group_id: String::new(),
654 name: String::new(),
655 created_by: String::new(),
656 created_at: String::new(),
657 members: Vec::new(),
658 },
659 Event::UserGroups { groups: Vec::new() },
660 Event::GroupError {
661 reason: String::new(),
662 group_id: None,
663 },
664 Event::GroupRelaySyncChanged {
665 group_id: String::new(),
666 synced: false,
667 reason: String::new(),
668 },
669 Event::UsernameResolved {
670 username: String::new(),
671 claims: Vec::new(),
672 rejected: 0,
673 truncated: 0,
674 },
675 Event::GroupMessageSent {
676 group_id: String::new(),
677 message_ids: Vec::new(),
678 member_count: 0,
679 },
680 Event::GroupMessagePartialFailure {
681 group_id: String::new(),
682 failed_members: Vec::new(),
683 succeeded_members: Vec::new(),
684 },
685 Event::GroupMessageDeliveryReport {
686 group_id: String::new(),
687 message_id: String::new(),
688 delivered: Vec::new(),
689 pushed: Vec::new(),
690 missed_reissued: Vec::new(),
691 },
692 Event::GroupRichExtrasDropped {
693 group_id: String::new(),
694 unknown_members: Vec::new(),
695 },
696 Event::GroupUnauthorizedMembershipChange {
697 group_id: String::new(),
698 committer: String::new(),
699 added: Vec::new(),
700 removed: Vec::new(),
701 reason: String::new(),
702 enforced: false,
703 },
704 Event::GroupEpochForkDetected {
705 group_id: String::new(),
706 local_epoch: None,
707 },
708 Event::GroupEpochForkResolved {
709 group_id: String::new(),
710 resolved_epoch: 0,
711 failed_members: Vec::new(),
712 },
713 Event::GroupRoleChanged {
714 group_id: String::new(),
715 user_id: String::new(),
716 new_role: String::new(),
717 changed_by: String::new(),
718 },
719 Event::GroupRenamed {
720 group_id: String::new(),
721 new_name: String::new(),
722 old_name: None,
723 renamed_by: String::new(),
724 },
725 Event::ServiceDiscovered {
726 query_id: String::new(),
727 service_id: String::new(),
728 version: String::new(),
729 provider_peer_id: String::new(),
730 capabilities: HashMap::new(),
731 hop_count: 0,
732 },
733 Event::ServiceRequestReceived {
734 request_id: String::new(),
735 service_id: String::new(),
736 method: String::new(),
737 body: String::new(),
738 sender: String::new(),
739 encrypted: false,
740 },
741 Event::ServiceResponseReceived {
742 request_id: String::new(),
743 service_id: String::new(),
744 status: String::new(),
745 body: String::new(),
746 provider_peer_id: String::new(),
747 encrypted: false,
748 },
749 Event::PresenceUpdated {
750 peer_id: String::new(),
751 status: PresenceStatus::Online,
752 timestamp: 0,
753 last_seen_ms: None,
754 source: PresenceSource::Internet,
755 },
756 Event::TypingIndicatorReceived {
757 sender: String::new(),
758 conversation_id: String::new(),
759 is_typing: false,
760 timestamp: 0,
761 },
762 Event::ReadReceiptReceived {
763 sender: String::new(),
764 message_ids: Vec::new(),
765 timestamp: 0,
766 },
767 Event::DorsScoreUpdated { scores: Vec::new() },
768 Event::DorsTransportSelected {
769 from: None,
770 transport: String::new(),
771 reason_code: DorsReasonCode::InitialSelection,
772 score: 0.0,
773 },
774 Event::DorsTransportSwitched {
775 from: None,
776 to: String::new(),
777 reason_code: DorsReasonCode::PrimarySelected,
778 reason_detail: None,
779 },
780 Event::DorsEscalationTriggered {
781 phase: DorsEscalationPhase::Triggered,
782 from: String::new(),
783 to: String::new(),
784 reason_code: DorsEscalationReasonCode::FallbackSuccess,
785 reason_detail: None,
786 },
787 Event::SecurityWarning {
788 peer_id: String::new(),
789 reason_code: SecurityWarningCode::SenderAddressMismatch,
790 reason: String::new(),
791 },
792 Event::MessageRelayed {
793 message_id: String::new(),
794 sender: String::new(),
795 recipient: String::new(),
796 hop_count: 0,
797 remaining_ttl: 0,
798 },
799 Event::UserBlocked {
800 user_id: String::new(),
801 },
802 Event::UserUnblocked {
803 user_id: String::new(),
804 },
805 Event::DataChanged {
806 space_id: String::new(),
807 doc_id: String::new(),
808 delta_bytes: 0,
809 },
810 Event::DataDocRemoved {
811 space_id: String::new(),
812 doc_id: String::new(),
813 by: crate::events::DocRemovedBy::Local,
814 },
815 Event::DataDocSizeWarning {
816 space_id: String::new(),
817 doc_id: String::new(),
818 compacted_bytes: 0,
819 cap_bytes: 0,
820 },
821 Event::DataAttachmentRequested {
822 space_id: String::new(),
823 peer_id: String::new(),
824 hash: String::new(),
825 },
826 Event::DataAttachmentReceived {
827 space_id: String::new(),
828 peer_id: String::new(),
829 hash: String::new(),
830 data: String::new(),
831 },
832 Event::DataAttachmentUnavailable {
833 space_id: String::new(),
834 peer_id: String::new(),
835 hash: String::new(),
836 reason: String::new(),
837 },
838 Event::DataDocUnsyncable {
839 space_id: String::new(),
840 doc_id: String::new(),
841 bytes: 0,
842 reason: String::new(),
843 },
844 ]
845 }
846
847 fn mls_lifecycle_exemplars() -> Vec<MlsLifecycleEvent> {
850 vec![
851 MlsLifecycleEvent::Initialized {
852 timestamp_ms: 0,
853 session_id: String::new(),
854 group_id: None,
855 peer_id: None,
856 context: MlsOperationContext::Initialize,
857 error_category: None,
858 },
859 MlsLifecycleEvent::EncryptionUsed {
860 timestamp_ms: 0,
861 session_id: String::new(),
862 group_id: None,
863 peer_id: None,
864 context: MlsOperationContext::Send,
865 error_category: None,
866 },
867 MlsLifecycleEvent::DecryptionFailed {
868 timestamp_ms: 0,
869 session_id: String::new(),
870 group_id: None,
871 peer_id: None,
872 context: MlsOperationContext::Receive,
873 error_category: None,
874 failure_kind: DecryptionFailureKind::Unknown,
875 },
876 MlsLifecycleEvent::SessionMissing {
877 timestamp_ms: 0,
878 session_id: String::new(),
879 group_id: None,
880 peer_id: None,
881 context: MlsOperationContext::SessionLookup,
882 error_category: None,
883 },
884 MlsLifecycleEvent::SessionEstablishing {
885 timestamp_ms: 0,
886 session_id: String::new(),
887 group_id: None,
888 peer_id: None,
889 context: MlsOperationContext::Welcome,
890 error_category: None,
891 },
892 MlsLifecycleEvent::SessionReady {
893 timestamp_ms: 0,
894 session_id: String::new(),
895 group_id: None,
896 peer_id: None,
897 context: MlsOperationContext::Welcome,
898 error_category: None,
899 },
900 ]
901 }
902
903 fn simple_record_names() -> Vec<&'static str> {
907 use crate::telemetry::device::DeviceCapabilitySnapshot;
908 use crate::telemetry::metrics_snapshot::MetricsFrame;
909 use crate::telemetry::routing::RoutingDecision;
910 use crate::telemetry::transport_state::TransportStateEvent;
911 vec![
912 MetricsFrame::NAME,
913 TransportStateEvent::NAME,
914 RoutingDecision::NAME,
915 DeviceCapabilitySnapshot::NAME,
916 ]
917 }
918
919 #[test]
927 fn impl_telemetry_names_are_unique() {
928 let mut names: Vec<&'static str> = Vec::new();
929 for e in event_exemplars() {
930 names.push(e.telemetry_name());
931 }
932 for e in mls_lifecycle_exemplars() {
933 names.push(e.telemetry_name());
934 }
935 names.extend(simple_record_names());
936 let unique: HashSet<&&'static str> = names.iter().collect();
937 assert_eq!(
938 unique.len(),
939 names.len(),
940 "two variants produced the same telemetry_name() — distinct \
941 events would silently merge at the sink. Names: {names:?}",
942 );
943 }
944
945 #[test]
952 fn impl_telemetry_names_match_catalogue() {
953 let mut impl_names: Vec<&'static str> = Vec::new();
954 for e in event_exemplars() {
955 impl_names.push(e.telemetry_name());
956 }
957 for e in mls_lifecycle_exemplars() {
958 impl_names.push(e.telemetry_name());
959 }
960 impl_names.extend(simple_record_names());
961
962 let impl_set: HashSet<&&'static str> = impl_names.iter().collect();
963 let catalogue_set: HashSet<&&str> = ALL_TELEMETRY_NAMES.iter().collect();
964
965 let missing_from_catalogue: Vec<&&'static str> = impl_set
966 .iter()
967 .copied()
968 .filter(|n| !ALL_TELEMETRY_NAMES.contains(*n))
969 .collect();
970 assert!(
971 missing_from_catalogue.is_empty(),
972 "implementation produces names not in ALL_TELEMETRY_NAMES: \
973 {missing_from_catalogue:?}. Update the catalogue.",
974 );
975
976 let stale_in_catalogue: Vec<&&str> = catalogue_set
977 .iter()
978 .copied()
979 .filter(|n| !impl_names.iter().any(|produced| produced == *n))
980 .collect();
981 assert!(
982 stale_in_catalogue.is_empty(),
983 "catalogue carries names the implementation does not produce: \
984 {stale_in_catalogue:?}. Remove from the catalogue or restore \
985 the match arm.",
986 );
987
988 assert_eq!(
989 impl_names.len(),
990 ALL_TELEMETRY_NAMES.len(),
991 "catalogue length ({}) differs from implementation variant \
992 count ({})",
993 ALL_TELEMETRY_NAMES.len(),
994 impl_names.len(),
995 );
996 }
997
998 #[test]
1004 fn catalogue_entries_are_unique() {
1005 let set: HashSet<&&str> = ALL_TELEMETRY_NAMES.iter().collect();
1006 assert_eq!(
1007 set.len(),
1008 ALL_TELEMETRY_NAMES.len(),
1009 "duplicate entry in ALL_TELEMETRY_NAMES",
1010 );
1011 }
1012
1013 #[test]
1014 fn all_telemetry_names_match_grammar() {
1015 for name in ALL_TELEMETRY_NAMES {
1016 assert!(
1017 is_well_formed(name),
1018 "telemetry name '{name}' does not match snake.dot.case grammar",
1019 );
1020 }
1021 }
1022
1023 #[test]
1024 fn grammar_checker_rejects_malformed_names() {
1025 assert!(!is_well_formed(""));
1026 assert!(!is_well_formed("flat"));
1027 assert!(!is_well_formed(".leading"));
1028 assert!(!is_well_formed("trailing."));
1029 assert!(!is_well_formed("double..dot"));
1030 assert!(!is_well_formed("Upper.Case"));
1031 assert!(!is_well_formed("has-dash.segment"));
1032 assert!(is_well_formed("ok.name"));
1033 assert!(is_well_formed("a.b.c"));
1034 assert!(is_well_formed("with_underscore.segment"));
1035 assert!(is_well_formed("has_digit1.seg2"));
1036 }
1037}