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