Skip to main content

offline_protocol/telemetry/
record.rs

1//! Unified telemetry record taxonomy.
2//!
3//! [`TelemetryRecord`] is the single typed surface that every SDK event and
4//! lifecycle signal flows through. Today it carries `Protocol` (wrapping
5//! [`crate::events::Event`]) and `Mls` (wrapping
6//! [`crate::mls_observability::MlsLifecycleEvent`]); additional variants
7//! (periodic metrics snapshots, transport state transitions, routing
8//! decisions, device-capability snapshots) are introduced alongside the emit
9//! sites that populate them in follow-up work.
10//!
11//! Names returned by [`TelemetryRecord::name`] follow a stable
12//! `snake.dot.case` grammar compatible with OpenTelemetry conventions: the
13//! string matches `[a-z0-9_]+(\.[a-z0-9_]+)+`. These names are the canonical
14//! wire identity for a record.
15//!
16//! The enum is `#[non_exhaustive]` so new categories can be added without a
17//! major-version bump. The record intentionally does not derive `Serialize`:
18//! the `name()` above is the canonical wire identity, and sinks that need
19//! on-the-wire serialization serialize the inner typed payload
20//! (`Event`/`MlsLifecycleEvent`) directly — this avoids maintaining two
21//! parallel naming schemes.
22
23use 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/// A single typed telemetry emission.
31///
32/// The name is delegated to the inner payload's `telemetry_name()` method so
33/// there is a single source of truth for variant naming.
34#[non_exhaustive]
35#[derive(Debug, Clone)]
36pub enum TelemetryRecord {
37    /// A protocol-level event — wraps the existing [`Event`] type.
38    ///
39    /// The inner `Event` is boxed because it is substantially larger than
40    /// every other variant; boxing keeps the enum's footprint flat so
41    /// non-`Protocol` records do not pay the size tax when stored in
42    /// collections.
43    Protocol(Box<Event>),
44    /// An MLS lifecycle event — wraps the existing [`MlsLifecycleEvent`].
45    Mls(MlsLifecycleEvent),
46    /// A periodic snapshot of protocol-wide counters and per-transport
47    /// metrics. Boxed to keep the enum footprint flat — the frame carries
48    /// inline `Vec`/stat structs that would otherwise dominate the enum
49    /// size and tax every non-snapshot variant.
50    MetricsSnapshot(Box<MetricsFrame>),
51    /// A single transport-status transition (per-transport, unlike the
52    /// protocol-level `Event::TransportSwitched`).
53    TransportState(TransportStateEvent),
54    /// A structured routing decision (supersets `Event::Dors*`).
55    ///
56    /// Boxed because the score-breakdown vector can carry per-transport
57    /// factor details at the `Diagnostic` verbosity tier.
58    Routing(Box<RoutingDecision>),
59    /// A snapshot of local device capability (battery, charging, relay role).
60    Device(DeviceCapabilitySnapshot),
61}
62
63impl TelemetryRecord {
64    /// Returns the stable name for this record. See the module-level docs
65    /// for the exact grammar and stability guarantees.
66    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    /// Hand-maintained catalogue of every `telemetry_name()` the SDK emits.
89    ///
90    /// This list is the wire-identity record for the release. It is
91    /// cross-checked against the real output of
92    /// [`Event::telemetry_name`]/[`MlsLifecycleEvent::telemetry_name`] by
93    /// `impl_telemetry_names_match_catalogue` below, which feeds one
94    /// exemplar per variant through the production mapping and asserts that
95    /// the resulting set equals this catalogue. Combined with the compile-
96    /// time exhaustiveness wards on [`event_exemplars`] and
97    /// [`mls_lifecycle_exemplars`], the three pieces together guarantee:
98    ///
99    /// 1. Every enum variant is represented in the exemplar list (the ward
100    ///    fails to compile when a new variant is added without an exemplar).
101    /// 2. No two variants produce the same name in the real implementation
102    ///    (`impl_telemetry_names_are_unique`).
103    /// 3. This catalogue is neither missing an entry nor carrying a stale
104    ///    one relative to the implementation
105    ///    (`impl_telemetry_names_match_catalogue`).
106    /// 4. Every name conforms to the documented `snake.dot.case` grammar
107    ///    (`all_telemetry_names_match_grammar`).
108    const ALL_TELEMETRY_NAMES: &[&str] = &[
109        // Event::*
110        "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.group.message_sent",
155        "protocol.group.message_partial_failure",
156        "protocol.group.delivery_report",
157        "protocol.group.rich_extras_dropped",
158        "protocol.group.unauthorized_membership_change",
159        "protocol.group.epoch_fork_detected",
160        "protocol.group.epoch_fork_resolved",
161        "protocol.group.role_changed",
162        "protocol.group.renamed",
163        "protocol.service.discovered",
164        "protocol.service.request_received",
165        "protocol.service.response_received",
166        "protocol.presence.updated",
167        "protocol.typing.received",
168        "protocol.read_receipt.received",
169        "protocol.dors.score_updated",
170        "protocol.dors.transport_selected",
171        "protocol.dors.transport_switched",
172        "protocol.dors.escalation_triggered",
173        "protocol.security.warning",
174        "protocol.message.relayed",
175        "protocol.user.blocked",
176        "protocol.user.unblocked",
177        // MlsLifecycleEvent::*
178        "mls.initialized",
179        "mls.encryption_used",
180        "mls.decryption_failed",
181        "mls.session_missing",
182        "mls.session_ready",
183        // Non-Event, non-MLS TelemetryRecord variants.
184        //
185        // These names are not covered by the Event/MlsLifecycleEvent
186        // exhaustiveness wards above because their source types are simple
187        // structs — there is no multi-variant enum to stay in sync with.
188        // The dedicated coverage check below
189        // (`simple_record_variant_names_are_stable`) exercises each one's
190        // `telemetry_name()` against the value inlined here.
191        "protocol.metrics.snapshot",
192        "protocol.transport.state_changed",
193        "protocol.routing.decision",
194        "protocol.device.capability_changed",
195    ];
196
197    /// Returns `true` when `name` matches the documented grammar
198    /// `[a-z0-9_]+(\.[a-z0-9_]+)+`.
199    fn is_well_formed(name: &str) -> bool {
200        if name.is_empty() {
201            return false;
202        }
203        let mut segments = name.split('.');
204        let mut segment_count = 0;
205        for segment in &mut segments {
206            if segment.is_empty() {
207                return false;
208            }
209            if !segment
210                .chars()
211                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
212            {
213                return false;
214            }
215            segment_count += 1;
216        }
217        segment_count >= 2
218    }
219
220    /// Regression guard for the `Box<Event>` decision on the `Protocol`
221    /// variant. `Event` is ~368 bytes; the non-Protocol variants are much
222    /// smaller (`MlsLifecycleEvent` is the next-largest at ~100 bytes). If
223    /// someone removes the `Box`, `TelemetryRecord` balloons to `Event`'s
224    /// size and the size tax is paid by every non-Protocol record stored in
225    /// a collection. This assertion fails loudly if that happens.
226    #[test]
227    fn telemetry_record_is_smaller_than_unboxed_event() {
228        use std::mem::size_of;
229        assert!(
230            size_of::<TelemetryRecord>() < size_of::<Event>(),
231            "TelemetryRecord ({} bytes) is not smaller than Event ({} bytes) \
232             — the `Box<Event>` variant was removed or the size budget was \
233             blown by another variant. Re-box the Protocol variant or audit \
234             the new variant's payload.",
235            size_of::<TelemetryRecord>(),
236            size_of::<Event>(),
237        );
238    }
239
240    #[test]
241    fn protocol_variant_names_are_dotted() {
242        let event = Event::MessageReceived {
243            message_id: "m".into(),
244            sender: "s".into(),
245            recipient: "r".into(),
246            content: String::new(),
247            reply_context: None,
248            hop_count: 0,
249            transport: "ble".into(),
250            timestamp: 0,
251            lamport_clock: 0,
252            reply_to_msg: None,
253            content_type: String::new(),
254            media_metadata: None,
255            forward_info: None,
256            encrypted: false,
257        };
258        assert_eq!(
259            TelemetryRecord::Protocol(Box::new(event)).name(),
260            "protocol.message.received",
261        );
262    }
263
264    #[test]
265    fn mls_variant_names_are_dotted() {
266        let event = MlsLifecycleEvent::Initialized {
267            timestamp_ms: 0,
268            session_id: "s".into(),
269            group_id: None,
270            peer_id: None,
271            context: MlsOperationContext::Initialize,
272            error_category: None,
273        };
274        assert_eq!(TelemetryRecord::Mls(event).name(), "mls.initialized");
275    }
276
277    /// Compile-time exhaustiveness ward for [`Event`].
278    ///
279    /// The match below deliberately omits a wildcard arm, so adding a new
280    /// variant to [`Event`] breaks compilation here and forces the author to
281    /// update [`event_exemplars`] to cover the new variant. Without this
282    /// ward, an author could add a variant + a `telemetry_name` arm while
283    /// silently skipping the exemplar list, and the impl-level uniqueness
284    /// check below would quietly run with incomplete coverage.
285    #[allow(dead_code)]
286    fn event_variant_exhaustiveness_ward(e: &Event) {
287        match e {
288            Event::MessageSent { .. }
289            | Event::MessageReceived { .. }
290            | Event::MessageDelivered { .. }
291            | Event::MessageFailed { .. }
292            | Event::MessageDecryptionFailed { .. }
293            | Event::TransportSwitched { .. }
294            | Event::RelayPromoted { .. }
295            | Event::RelayDemoted { .. }
296            | Event::NeighborDiscovered { .. }
297            | Event::NeighborLost { .. }
298            | Event::IdentityReady { .. }
299            | Event::NetworkMetrics { .. }
300            | Event::FileProgress { .. }
301            | Event::FileReceived { .. }
302            | Event::FileReceiveFailed { .. }
303            | Event::MediaSent { .. }
304            | Event::MediaSendFailed { .. }
305            | Event::MessageDeferred { .. }
306            | Event::MessageRetrying { .. }
307            | Event::MessageUndeliverable { .. }
308            | Event::MediaResendRequired { .. }
309            | Event::AckEvicted { .. }
310            | Event::FragmentAssemblyEvicted { .. }
311            | Event::RelayDemotedBattery { .. }
312            | Event::SecureSessionEstablished { .. }
313            | Event::SecureSessionFailed { .. }
314            | Event::ConvergenceDiag { .. }
315            | Event::WelcomeSendAttempted { .. }
316            | Event::WelcomeSendSucceeded { .. }
317            | Event::WelcomeSendFailed { .. }
318            | Event::WelcomeSendExpired { .. }
319            | Event::ConnectionRequestReceived { .. }
320            | Event::ConnectionRequestUndeliverable { .. }
321            | Event::ConnectionAccepted { .. }
322            | Event::ConnectionRejected { .. }
323            | Event::ConnectionRequestCancelled { .. }
324            | Event::GroupCreated { .. }
325            | Event::GroupMessageReceived { .. }
326            | Event::GroupMemberAdded { .. }
327            | Event::GroupMemberRemoved { .. }
328            | Event::GroupInfo { .. }
329            | Event::UserGroups { .. }
330            | Event::GroupError { .. }
331            | Event::GroupRelaySyncChanged { .. }
332            | Event::GroupMessageSent { .. }
333            | Event::GroupMessagePartialFailure { .. }
334            | Event::GroupMessageDeliveryReport { .. }
335            | Event::GroupRichExtrasDropped { .. }
336            | Event::GroupUnauthorizedMembershipChange { .. }
337            | Event::GroupEpochForkDetected { .. }
338            | Event::GroupEpochForkResolved { .. }
339            | Event::GroupRoleChanged { .. }
340            | Event::GroupRenamed { .. }
341            | Event::ServiceDiscovered { .. }
342            | Event::ServiceRequestReceived { .. }
343            | Event::ServiceResponseReceived { .. }
344            | Event::PresenceUpdated { .. }
345            | Event::TypingIndicatorReceived { .. }
346            | Event::ReadReceiptReceived { .. }
347            | Event::DorsScoreUpdated { .. }
348            | Event::DorsTransportSelected { .. }
349            | Event::DorsTransportSwitched { .. }
350            | Event::DorsEscalationTriggered { .. }
351            | Event::SecurityWarning { .. }
352            | Event::MessageRelayed { .. }
353            | Event::UserBlocked { .. }
354            | Event::UserUnblocked { .. } => (),
355        }
356    }
357
358    /// Compile-time exhaustiveness ward for [`MlsLifecycleEvent`] — see
359    /// [`event_variant_exhaustiveness_ward`] for the rationale.
360    #[allow(dead_code)]
361    fn mls_lifecycle_exhaustiveness_ward(e: &MlsLifecycleEvent) {
362        match e {
363            MlsLifecycleEvent::Initialized { .. }
364            | MlsLifecycleEvent::EncryptionUsed { .. }
365            | MlsLifecycleEvent::DecryptionFailed { .. }
366            | MlsLifecycleEvent::SessionMissing { .. }
367            | MlsLifecycleEvent::SessionReady { .. } => (),
368        }
369    }
370
371    /// Constructs one exemplar per [`Event`] variant, in the same order as
372    /// the match arms of [`Event::telemetry_name`]. Field values are
373    /// placeholders — the tests only read the telemetry name.
374    fn event_exemplars() -> Vec<Event> {
375        vec![
376            Event::MessageSent {
377                message_id: String::new(),
378                sender: String::new(),
379                recipient: String::new(),
380                content: String::new(),
381                priority: String::new(),
382                requires_ack: false,
383                timestamp: 0,
384                lamport_clock: 0,
385                forward_info: None,
386            },
387            Event::MessageReceived {
388                message_id: String::new(),
389                sender: String::new(),
390                recipient: String::new(),
391                content: String::new(),
392                hop_count: 0,
393                transport: String::new(),
394                timestamp: 0,
395                lamport_clock: 0,
396                reply_to_msg: None,
397                reply_context: None,
398                content_type: String::new(),
399                media_metadata: None,
400                forward_info: None,
401                encrypted: false,
402            },
403            Event::MessageDelivered {
404                message_id: String::new(),
405                latency_ms: 0,
406                hop_count: 0,
407                transport: String::new(),
408            },
409            Event::MessageFailed {
410                message_id: String::new(),
411                reason: String::new(),
412                retry_count: 0,
413            },
414            Event::MessageDecryptionFailed {
415                message_id: String::new(),
416                sender: String::new(),
417                code: crate::events::DecryptionFailureCode::Unknown,
418                reason: String::new(),
419            },
420            Event::TransportSwitched {
421                from: None,
422                to: String::new(),
423                reason: String::new(),
424            },
425            Event::RelayPromoted {
426                connection_count: 0,
427                battery_level: 0,
428            },
429            Event::RelayDemoted {
430                reason: String::new(),
431            },
432            Event::NeighborDiscovered {
433                peer_id: String::new(),
434                transport: String::new(),
435                rssi: None,
436            },
437            Event::NeighborLost {
438                peer_id: String::new(),
439            },
440            Event::IdentityReady {
441                address: String::new(),
442            },
443            Event::NetworkMetrics {
444                neighbor_count: 0,
445                relay_count: 0,
446                delivery_ratio: 0.0,
447                avg_latency_ms: 0,
448            },
449            Event::FileProgress {
450                file_id: String::new(),
451                chunks_sent: 0,
452                total_chunks: 0,
453                percentage: 0,
454            },
455            Event::FileReceived {
456                file_id: String::new(),
457                file_name: String::new(),
458                file_size: 0,
459                sender: String::new(),
460                content_type: String::new(),
461                media_metadata: None,
462                file_data: String::new(),
463                timestamp: None,
464                caption: None,
465                reply_to_msg: None,
466                reply_context: None,
467                forward_info: None,
468            },
469            Event::FileReceiveFailed {
470                file_id: String::new(),
471                file_name: String::new(),
472                sender: String::new(),
473                reason: String::new(),
474            },
475            Event::MediaSent {
476                file_id: String::new(),
477                content_type: String::new(),
478                recipient: String::new(),
479            },
480            Event::MediaSendFailed {
481                file_id: String::new(),
482                recipient: String::new(),
483                reason: String::new(),
484            },
485            Event::MessageDeferred {
486                message_id: String::new(),
487                recipient: String::new(),
488                reason: String::new(),
489                retry_count: 0,
490                next_retry_at: None,
491            },
492            Event::MessageRetrying {
493                message_id: String::new(),
494                recipient: String::new(),
495                retry_count: 0,
496                next_retry_at: 0,
497            },
498            Event::MessageUndeliverable {
499                message_id: String::new(),
500                recipient: String::new(),
501                reason: String::new(),
502                file_id: None,
503            },
504            Event::MediaResendRequired {
505                file_id: String::new(),
506                recipient: String::new(),
507                file_name: String::new(),
508                file_size: 0,
509            },
510            Event::AckEvicted {
511                message_id: String::new(),
512                priority: String::new(),
513                reason: String::new(),
514            },
515            Event::FragmentAssemblyEvicted {
516                message_id: String::new(),
517                completion_percent: 0,
518                reason: String::new(),
519            },
520            Event::RelayDemotedBattery {
521                battery_level: 0,
522                min_required: 0,
523            },
524            Event::SecureSessionEstablished {
525                peer_id: String::new(),
526                group_id: String::new(),
527                is_session: false,
528                initiated_by_local: false,
529            },
530            Event::SecureSessionFailed {
531                peer_id: String::new(),
532                reason: String::new(),
533            },
534            Event::ConvergenceDiag {
535                stage: String::new(),
536                peer_id: String::new(),
537                detail: String::new(),
538            },
539            Event::WelcomeSendAttempted {
540                peer_id: String::new(),
541                message_id: String::new(),
542                group_id: String::new(),
543                attempt: 0,
544            },
545            Event::WelcomeSendSucceeded {
546                peer_id: String::new(),
547                message_id: String::new(),
548                group_id: String::new(),
549                attempt: 0,
550            },
551            Event::WelcomeSendFailed {
552                peer_id: String::new(),
553                message_id: String::new(),
554                group_id: String::new(),
555                attempt: 0,
556                reason_code: WelcomeReasonCode::InternalError,
557                transport_error: None,
558                retryable: false,
559                next_retry_at: None,
560            },
561            Event::WelcomeSendExpired {
562                peer_id: String::new(),
563                message_id: String::new(),
564                attempt: 0,
565                reason_code: WelcomeReasonCode::RetryExhausted,
566            },
567            Event::ConnectionRequestReceived {
568                sender: String::new(),
569                sender_name: String::new(),
570                timestamp: 0,
571                key_package: None,
572                initial_message: None,
573            },
574            Event::ConnectionRequestUndeliverable {
575                recipient: String::new(),
576                message_id: String::new(),
577                reason: String::new(),
578            },
579            Event::ConnectionAccepted {
580                accepted_by: String::new(),
581                accepted_by_name: String::new(),
582                timestamp: 0,
583                key_package: None,
584            },
585            Event::ConnectionRejected {
586                rejected_by: String::new(),
587            },
588            Event::ConnectionRequestCancelled {
589                cancelled_by: String::new(),
590            },
591            Event::GroupCreated {
592                group_id: String::new(),
593                name: String::new(),
594            },
595            Event::GroupMessageReceived {
596                group_id: String::new(),
597                sender: String::new(),
598                content: String::new(),
599                timestamp: String::new(),
600                message_id: String::new(),
601                reply_to_msg: None,
602                forward_info: None,
603                media_metadata: None,
604                content_type: None,
605            },
606            Event::GroupMemberAdded {
607                group_id: String::new(),
608                user_id: String::new(),
609                added_by: String::new(),
610                group_name: None,
611                authorized: Some(true),
612            },
613            Event::GroupMemberRemoved {
614                group_id: String::new(),
615                user_id: String::new(),
616                removed_by: String::new(),
617                authorized: Some(true),
618            },
619            Event::GroupInfo {
620                group_id: String::new(),
621                name: String::new(),
622                created_by: String::new(),
623                created_at: String::new(),
624                members: Vec::new(),
625            },
626            Event::UserGroups { groups: Vec::new() },
627            Event::GroupError {
628                reason: String::new(),
629                group_id: None,
630            },
631            Event::GroupRelaySyncChanged {
632                group_id: String::new(),
633                synced: false,
634                reason: String::new(),
635            },
636            Event::GroupMessageSent {
637                group_id: String::new(),
638                message_ids: Vec::new(),
639                member_count: 0,
640            },
641            Event::GroupMessagePartialFailure {
642                group_id: String::new(),
643                failed_members: Vec::new(),
644                succeeded_members: Vec::new(),
645            },
646            Event::GroupMessageDeliveryReport {
647                group_id: String::new(),
648                message_id: String::new(),
649                delivered: Vec::new(),
650                pushed: Vec::new(),
651                missed_reissued: Vec::new(),
652            },
653            Event::GroupRichExtrasDropped {
654                group_id: String::new(),
655                unknown_members: Vec::new(),
656            },
657            Event::GroupUnauthorizedMembershipChange {
658                group_id: String::new(),
659                committer: String::new(),
660                added: Vec::new(),
661                removed: Vec::new(),
662                reason: String::new(),
663                enforced: false,
664            },
665            Event::GroupEpochForkDetected {
666                group_id: String::new(),
667                local_epoch: None,
668            },
669            Event::GroupEpochForkResolved {
670                group_id: String::new(),
671                resolved_epoch: 0,
672                failed_members: Vec::new(),
673            },
674            Event::GroupRoleChanged {
675                group_id: String::new(),
676                user_id: String::new(),
677                new_role: String::new(),
678                changed_by: String::new(),
679            },
680            Event::GroupRenamed {
681                group_id: String::new(),
682                new_name: String::new(),
683                old_name: None,
684                renamed_by: String::new(),
685            },
686            Event::ServiceDiscovered {
687                query_id: String::new(),
688                service_id: String::new(),
689                version: String::new(),
690                provider_peer_id: String::new(),
691                capabilities: HashMap::new(),
692                hop_count: 0,
693            },
694            Event::ServiceRequestReceived {
695                request_id: String::new(),
696                service_id: String::new(),
697                method: String::new(),
698                body: String::new(),
699                sender: String::new(),
700            },
701            Event::ServiceResponseReceived {
702                request_id: String::new(),
703                service_id: String::new(),
704                status: String::new(),
705                body: String::new(),
706                provider_peer_id: String::new(),
707            },
708            Event::PresenceUpdated {
709                peer_id: String::new(),
710                status: PresenceStatus::Online,
711                timestamp: 0,
712                last_seen_ms: None,
713                source: PresenceSource::Internet,
714            },
715            Event::TypingIndicatorReceived {
716                sender: String::new(),
717                conversation_id: String::new(),
718                is_typing: false,
719                timestamp: 0,
720            },
721            Event::ReadReceiptReceived {
722                sender: String::new(),
723                message_ids: Vec::new(),
724                timestamp: 0,
725            },
726            Event::DorsScoreUpdated { scores: Vec::new() },
727            Event::DorsTransportSelected {
728                from: None,
729                transport: String::new(),
730                reason_code: DorsReasonCode::InitialSelection,
731                score: 0.0,
732            },
733            Event::DorsTransportSwitched {
734                from: None,
735                to: String::new(),
736                reason_code: DorsReasonCode::PrimarySelected,
737                reason_detail: None,
738            },
739            Event::DorsEscalationTriggered {
740                phase: DorsEscalationPhase::Triggered,
741                from: String::new(),
742                to: String::new(),
743                reason_code: DorsEscalationReasonCode::FallbackSuccess,
744                reason_detail: None,
745            },
746            Event::SecurityWarning {
747                peer_id: String::new(),
748                reason_code: SecurityWarningCode::SenderAddressMismatch,
749                reason: String::new(),
750            },
751            Event::MessageRelayed {
752                message_id: String::new(),
753                sender: String::new(),
754                recipient: String::new(),
755                hop_count: 0,
756                remaining_ttl: 0,
757            },
758            Event::UserBlocked {
759                user_id: String::new(),
760            },
761            Event::UserUnblocked {
762                user_id: String::new(),
763            },
764        ]
765    }
766
767    /// Constructs one exemplar per [`MlsLifecycleEvent`] variant, in the same
768    /// order as the match arms of [`MlsLifecycleEvent::telemetry_name`].
769    fn mls_lifecycle_exemplars() -> Vec<MlsLifecycleEvent> {
770        vec![
771            MlsLifecycleEvent::Initialized {
772                timestamp_ms: 0,
773                session_id: String::new(),
774                group_id: None,
775                peer_id: None,
776                context: MlsOperationContext::Initialize,
777                error_category: None,
778            },
779            MlsLifecycleEvent::EncryptionUsed {
780                timestamp_ms: 0,
781                session_id: String::new(),
782                group_id: None,
783                peer_id: None,
784                context: MlsOperationContext::Send,
785                error_category: None,
786            },
787            MlsLifecycleEvent::DecryptionFailed {
788                timestamp_ms: 0,
789                session_id: String::new(),
790                group_id: None,
791                peer_id: None,
792                context: MlsOperationContext::Receive,
793                error_category: None,
794                failure_kind: DecryptionFailureKind::Unknown,
795            },
796            MlsLifecycleEvent::SessionMissing {
797                timestamp_ms: 0,
798                session_id: String::new(),
799                group_id: None,
800                peer_id: None,
801                context: MlsOperationContext::SessionLookup,
802                error_category: None,
803            },
804            MlsLifecycleEvent::SessionReady {
805                timestamp_ms: 0,
806                session_id: String::new(),
807                group_id: None,
808                peer_id: None,
809                context: MlsOperationContext::Welcome,
810                error_category: None,
811            },
812        ]
813    }
814
815    /// Names emitted by simple-struct telemetry records (non-enum inner
816    /// types). Extends the Event/MLS-derived name lists used by the
817    /// uniqueness and catalogue-match checks.
818    fn simple_record_names() -> Vec<&'static str> {
819        use crate::telemetry::device::DeviceCapabilitySnapshot;
820        use crate::telemetry::metrics_snapshot::MetricsFrame;
821        use crate::telemetry::routing::RoutingDecision;
822        use crate::telemetry::transport_state::TransportStateEvent;
823        vec![
824            MetricsFrame::NAME,
825            TransportStateEvent::NAME,
826            RoutingDecision::NAME,
827            DeviceCapabilitySnapshot::NAME,
828        ]
829    }
830
831    /// Real implementation-level uniqueness check.
832    ///
833    /// Feeds every exemplar through [`Event::telemetry_name`] and
834    /// [`MlsLifecycleEvent::telemetry_name`] and asserts the produced names
835    /// are unique. Unlike a catalogue-only uniqueness check, this catches
836    /// the case where two match arms in the production mapping accidentally
837    /// produce the same string — a silent merge at the sink.
838    #[test]
839    fn impl_telemetry_names_are_unique() {
840        let mut names: Vec<&'static str> = Vec::new();
841        for e in event_exemplars() {
842            names.push(e.telemetry_name());
843        }
844        for e in mls_lifecycle_exemplars() {
845            names.push(e.telemetry_name());
846        }
847        names.extend(simple_record_names());
848        let unique: HashSet<&&'static str> = names.iter().collect();
849        assert_eq!(
850            unique.len(),
851            names.len(),
852            "two variants produced the same telemetry_name() — distinct \
853             events would silently merge at the sink. Names: {names:?}",
854        );
855    }
856
857    /// Guards against drift between the catalogue and the implementation.
858    ///
859    /// If a new variant is added without a catalogue entry, the catalogue
860    /// will be short one name. If a catalogue entry is left behind after a
861    /// variant rename, the catalogue will carry a stale name. Either failure
862    /// mode is caught here.
863    #[test]
864    fn impl_telemetry_names_match_catalogue() {
865        let mut impl_names: Vec<&'static str> = Vec::new();
866        for e in event_exemplars() {
867            impl_names.push(e.telemetry_name());
868        }
869        for e in mls_lifecycle_exemplars() {
870            impl_names.push(e.telemetry_name());
871        }
872        impl_names.extend(simple_record_names());
873
874        let impl_set: HashSet<&&'static str> = impl_names.iter().collect();
875        let catalogue_set: HashSet<&&str> = ALL_TELEMETRY_NAMES.iter().collect();
876
877        let missing_from_catalogue: Vec<&&'static str> = impl_set
878            .iter()
879            .copied()
880            .filter(|n| !ALL_TELEMETRY_NAMES.contains(*n))
881            .collect();
882        assert!(
883            missing_from_catalogue.is_empty(),
884            "implementation produces names not in ALL_TELEMETRY_NAMES: \
885             {missing_from_catalogue:?}. Update the catalogue.",
886        );
887
888        let stale_in_catalogue: Vec<&&str> = catalogue_set
889            .iter()
890            .copied()
891            .filter(|n| !impl_names.iter().any(|produced| produced == *n))
892            .collect();
893        assert!(
894            stale_in_catalogue.is_empty(),
895            "catalogue carries names the implementation does not produce: \
896             {stale_in_catalogue:?}. Remove from the catalogue or restore \
897             the match arm.",
898        );
899
900        assert_eq!(
901            impl_names.len(),
902            ALL_TELEMETRY_NAMES.len(),
903            "catalogue length ({}) differs from implementation variant \
904             count ({})",
905            ALL_TELEMETRY_NAMES.len(),
906            impl_names.len(),
907        );
908    }
909
910    /// Sanity check on the catalogue itself — duplicate entries would mask
911    /// drift against the implementation (two catalogue entries colliding
912    /// with a single implementation name would still pass the equality
913    /// check above). Kept alongside the implementation check so both
914    /// invariants are locked in.
915    #[test]
916    fn catalogue_entries_are_unique() {
917        let set: HashSet<&&str> = ALL_TELEMETRY_NAMES.iter().collect();
918        assert_eq!(
919            set.len(),
920            ALL_TELEMETRY_NAMES.len(),
921            "duplicate entry in ALL_TELEMETRY_NAMES",
922        );
923    }
924
925    #[test]
926    fn all_telemetry_names_match_grammar() {
927        for name in ALL_TELEMETRY_NAMES {
928            assert!(
929                is_well_formed(name),
930                "telemetry name '{name}' does not match snake.dot.case grammar",
931            );
932        }
933    }
934
935    #[test]
936    fn grammar_checker_rejects_malformed_names() {
937        assert!(!is_well_formed(""));
938        assert!(!is_well_formed("flat"));
939        assert!(!is_well_formed(".leading"));
940        assert!(!is_well_formed("trailing."));
941        assert!(!is_well_formed("double..dot"));
942        assert!(!is_well_formed("Upper.Case"));
943        assert!(!is_well_formed("has-dash.segment"));
944        assert!(is_well_formed("ok.name"));
945        assert!(is_well_formed("a.b.c"));
946        assert!(is_well_formed("with_underscore.segment"));
947        assert!(is_well_formed("has_digit1.seg2"));
948    }
949}