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