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