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