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