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                sealed_at_ms: None,
440                session_established_at_ms: None,
441            },
442            Event::TransportSwitched {
443                from: None,
444                to: String::new(),
445                reason: String::new(),
446            },
447            Event::RelayPromoted {
448                connection_count: 0,
449                battery_level: None,
450            },
451            Event::RelayDemoted {
452                reason: String::new(),
453            },
454            Event::NeighborDiscovered {
455                peer_id: String::new(),
456                transport: String::new(),
457                rssi: None,
458            },
459            Event::NeighborLost {
460                peer_id: String::new(),
461            },
462            Event::IdentityReady {
463                address: String::new(),
464            },
465            Event::NetworkMetrics {
466                neighbor_count: 0,
467                relay_count: 0,
468                delivery_ratio: 0.0,
469                avg_latency_ms: 0,
470            },
471            Event::FileProgress {
472                file_id: String::new(),
473                chunks_sent: 0,
474                total_chunks: 0,
475                percentage: 0,
476            },
477            Event::FileReceived {
478                file_id: String::new(),
479                file_name: String::new(),
480                file_size: 0,
481                sender: String::new(),
482                content_type: String::new(),
483                media_metadata: None,
484                file_data: String::new(),
485                timestamp: None,
486                caption: None,
487                reply_to_msg: None,
488                reply_context: None,
489                forward_info: None,
490                app_id: None,
491            },
492            Event::FileReceiveFailed {
493                file_id: String::new(),
494                file_name: String::new(),
495                sender: String::new(),
496                reason: String::new(),
497            },
498            Event::MediaSent {
499                file_id: String::new(),
500                content_type: String::new(),
501                recipient: String::new(),
502            },
503            Event::MediaSendFailed {
504                file_id: String::new(),
505                recipient: String::new(),
506                reason: String::new(),
507            },
508            Event::MessageDeferred {
509                message_id: String::new(),
510                recipient: String::new(),
511                reason: String::new(),
512                retry_count: 0,
513                next_retry_at: None,
514            },
515            Event::MessageRetrying {
516                message_id: String::new(),
517                recipient: String::new(),
518                retry_count: 0,
519                next_retry_at: 0,
520            },
521            Event::MessageUndeliverable {
522                message_id: String::new(),
523                recipient: String::new(),
524                reason: String::new(),
525                file_id: None,
526            },
527            Event::MediaResendRequired {
528                file_id: String::new(),
529                recipient: String::new(),
530                file_name: String::new(),
531                file_size: 0,
532                app_id: None,
533            },
534            Event::AckEvicted {
535                message_id: String::new(),
536                priority: String::new(),
537                reason: String::new(),
538            },
539            Event::FragmentAssemblyEvicted {
540                message_id: String::new(),
541                completion_percent: 0,
542                reason: String::new(),
543            },
544            Event::RelayDemotedBattery {
545                battery_level: 0,
546                min_required: 0,
547            },
548            Event::SecureSessionEstablished {
549                peer_id: String::new(),
550                group_id: String::new(),
551                is_session: false,
552                initiated_by_local: false,
553            },
554            Event::SecureSessionFailed {
555                peer_id: String::new(),
556                reason: String::new(),
557            },
558            Event::ConvergenceDiag {
559                stage: String::new(),
560                peer_id: String::new(),
561                detail: String::new(),
562            },
563            Event::WelcomeSendAttempted {
564                peer_id: String::new(),
565                message_id: String::new(),
566                group_id: String::new(),
567                attempt: 0,
568            },
569            Event::WelcomeSendSucceeded {
570                peer_id: String::new(),
571                message_id: String::new(),
572                group_id: String::new(),
573                attempt: 0,
574            },
575            Event::WelcomeSendFailed {
576                peer_id: String::new(),
577                message_id: String::new(),
578                group_id: String::new(),
579                attempt: 0,
580                reason_code: WelcomeReasonCode::InternalError,
581                transport_error: None,
582                retryable: false,
583                next_retry_at: None,
584            },
585            Event::WelcomeSendExpired {
586                peer_id: String::new(),
587                message_id: String::new(),
588                attempt: 0,
589                reason_code: WelcomeReasonCode::RetryExhausted,
590            },
591            Event::ConnectionRequestReceived {
592                sender: String::new(),
593                sender_name: String::new(),
594                timestamp: 0,
595                key_package: None,
596                initial_message: None,
597            },
598            Event::ConnectionRequestUndeliverable {
599                recipient: String::new(),
600                message_id: String::new(),
601                reason: String::new(),
602            },
603            Event::ConnectionAccepted {
604                accepted_by: String::new(),
605                accepted_by_name: String::new(),
606                timestamp: 0,
607                key_package: None,
608            },
609            Event::ConnectionRejected {
610                rejected_by: String::new(),
611            },
612            Event::ConnectionRequestCancelled {
613                cancelled_by: String::new(),
614            },
615            Event::GroupCreated {
616                group_id: String::new(),
617                name: String::new(),
618            },
619            Event::GroupMessageReceived {
620                group_id: String::new(),
621                sender: String::new(),
622                content: String::new(),
623                timestamp: String::new(),
624                message_id: String::new(),
625                reply_to_msg: None,
626                forward_info: None,
627                media_metadata: None,
628                content_type: None,
629            },
630            Event::GroupMemberAdded {
631                group_id: String::new(),
632                user_id: String::new(),
633                added_by: String::new(),
634                group_name: None,
635                authorized: Some(true),
636            },
637            Event::GroupMemberRemoved {
638                group_id: String::new(),
639                user_id: String::new(),
640                removed_by: String::new(),
641                authorized: Some(true),
642            },
643            Event::GroupInfo {
644                group_id: String::new(),
645                name: String::new(),
646                created_by: String::new(),
647                created_at: String::new(),
648                members: Vec::new(),
649            },
650            Event::UserGroups { groups: Vec::new() },
651            Event::GroupError {
652                reason: String::new(),
653                group_id: None,
654            },
655            Event::GroupRelaySyncChanged {
656                group_id: String::new(),
657                synced: false,
658                reason: String::new(),
659            },
660            Event::UsernameResolved {
661                username: String::new(),
662                claims: Vec::new(),
663                rejected: 0,
664                truncated: 0,
665            },
666            Event::GroupMessageSent {
667                group_id: String::new(),
668                message_ids: Vec::new(),
669                member_count: 0,
670            },
671            Event::GroupMessagePartialFailure {
672                group_id: String::new(),
673                failed_members: Vec::new(),
674                succeeded_members: Vec::new(),
675            },
676            Event::GroupMessageDeliveryReport {
677                group_id: String::new(),
678                message_id: String::new(),
679                delivered: Vec::new(),
680                pushed: Vec::new(),
681                missed_reissued: Vec::new(),
682            },
683            Event::GroupRichExtrasDropped {
684                group_id: String::new(),
685                unknown_members: Vec::new(),
686            },
687            Event::GroupUnauthorizedMembershipChange {
688                group_id: String::new(),
689                committer: String::new(),
690                added: Vec::new(),
691                removed: Vec::new(),
692                reason: String::new(),
693                enforced: false,
694            },
695            Event::GroupEpochForkDetected {
696                group_id: String::new(),
697                local_epoch: None,
698            },
699            Event::GroupEpochForkResolved {
700                group_id: String::new(),
701                resolved_epoch: 0,
702                failed_members: Vec::new(),
703            },
704            Event::GroupRoleChanged {
705                group_id: String::new(),
706                user_id: String::new(),
707                new_role: String::new(),
708                changed_by: String::new(),
709            },
710            Event::GroupRenamed {
711                group_id: String::new(),
712                new_name: String::new(),
713                old_name: None,
714                renamed_by: String::new(),
715            },
716            Event::ServiceDiscovered {
717                query_id: String::new(),
718                service_id: String::new(),
719                version: String::new(),
720                provider_peer_id: String::new(),
721                capabilities: HashMap::new(),
722                hop_count: 0,
723            },
724            Event::ServiceRequestReceived {
725                request_id: String::new(),
726                service_id: String::new(),
727                method: String::new(),
728                body: String::new(),
729                sender: String::new(),
730                encrypted: false,
731            },
732            Event::ServiceResponseReceived {
733                request_id: String::new(),
734                service_id: String::new(),
735                status: String::new(),
736                body: String::new(),
737                provider_peer_id: String::new(),
738                encrypted: false,
739            },
740            Event::PresenceUpdated {
741                peer_id: String::new(),
742                status: PresenceStatus::Online,
743                timestamp: 0,
744                last_seen_ms: None,
745                source: PresenceSource::Internet,
746            },
747            Event::TypingIndicatorReceived {
748                sender: String::new(),
749                conversation_id: String::new(),
750                is_typing: false,
751                timestamp: 0,
752            },
753            Event::ReadReceiptReceived {
754                sender: String::new(),
755                message_ids: Vec::new(),
756                timestamp: 0,
757            },
758            Event::DorsScoreUpdated { scores: Vec::new() },
759            Event::DorsTransportSelected {
760                from: None,
761                transport: String::new(),
762                reason_code: DorsReasonCode::InitialSelection,
763                score: 0.0,
764            },
765            Event::DorsTransportSwitched {
766                from: None,
767                to: String::new(),
768                reason_code: DorsReasonCode::PrimarySelected,
769                reason_detail: None,
770            },
771            Event::DorsEscalationTriggered {
772                phase: DorsEscalationPhase::Triggered,
773                from: String::new(),
774                to: String::new(),
775                reason_code: DorsEscalationReasonCode::FallbackSuccess,
776                reason_detail: None,
777            },
778            Event::SecurityWarning {
779                peer_id: String::new(),
780                reason_code: SecurityWarningCode::SenderAddressMismatch,
781                reason: String::new(),
782            },
783            Event::MessageRelayed {
784                message_id: String::new(),
785                sender: String::new(),
786                recipient: String::new(),
787                hop_count: 0,
788                remaining_ttl: 0,
789            },
790            Event::UserBlocked {
791                user_id: String::new(),
792            },
793            Event::UserUnblocked {
794                user_id: String::new(),
795            },
796            Event::DataChanged {
797                space_id: String::new(),
798                doc_id: String::new(),
799                delta_bytes: 0,
800            },
801            Event::DataDocRemoved {
802                space_id: String::new(),
803                doc_id: String::new(),
804                by: crate::events::DocRemovedBy::Local,
805            },
806            Event::DataDocSizeWarning {
807                space_id: String::new(),
808                doc_id: String::new(),
809                compacted_bytes: 0,
810                cap_bytes: 0,
811            },
812            Event::DataAttachmentRequested {
813                space_id: String::new(),
814                peer_id: String::new(),
815                hash: String::new(),
816            },
817            Event::DataAttachmentReceived {
818                space_id: String::new(),
819                peer_id: String::new(),
820                hash: String::new(),
821                data: String::new(),
822            },
823            Event::DataAttachmentUnavailable {
824                space_id: String::new(),
825                peer_id: String::new(),
826                hash: String::new(),
827                reason: String::new(),
828            },
829            Event::DataDocUnsyncable {
830                space_id: String::new(),
831                doc_id: String::new(),
832                bytes: 0,
833                reason: String::new(),
834            },
835        ]
836    }
837
838    /// Constructs one exemplar per [`MlsLifecycleEvent`] variant, in the same
839    /// order as the match arms of [`MlsLifecycleEvent::telemetry_name`].
840    fn mls_lifecycle_exemplars() -> Vec<MlsLifecycleEvent> {
841        vec![
842            MlsLifecycleEvent::Initialized {
843                timestamp_ms: 0,
844                session_id: String::new(),
845                group_id: None,
846                peer_id: None,
847                context: MlsOperationContext::Initialize,
848                error_category: None,
849            },
850            MlsLifecycleEvent::EncryptionUsed {
851                timestamp_ms: 0,
852                session_id: String::new(),
853                group_id: None,
854                peer_id: None,
855                context: MlsOperationContext::Send,
856                error_category: None,
857            },
858            MlsLifecycleEvent::DecryptionFailed {
859                timestamp_ms: 0,
860                session_id: String::new(),
861                group_id: None,
862                peer_id: None,
863                context: MlsOperationContext::Receive,
864                error_category: None,
865                failure_kind: DecryptionFailureKind::Unknown,
866            },
867            MlsLifecycleEvent::SessionMissing {
868                timestamp_ms: 0,
869                session_id: String::new(),
870                group_id: None,
871                peer_id: None,
872                context: MlsOperationContext::SessionLookup,
873                error_category: None,
874            },
875            MlsLifecycleEvent::SessionEstablishing {
876                timestamp_ms: 0,
877                session_id: String::new(),
878                group_id: None,
879                peer_id: None,
880                context: MlsOperationContext::Welcome,
881                error_category: None,
882            },
883            MlsLifecycleEvent::SessionReady {
884                timestamp_ms: 0,
885                session_id: String::new(),
886                group_id: None,
887                peer_id: None,
888                context: MlsOperationContext::Welcome,
889                error_category: None,
890            },
891        ]
892    }
893
894    /// Names emitted by simple-struct telemetry records (non-enum inner
895    /// types). Extends the Event/MLS-derived name lists used by the
896    /// uniqueness and catalogue-match checks.
897    fn simple_record_names() -> Vec<&'static str> {
898        use crate::telemetry::device::DeviceCapabilitySnapshot;
899        use crate::telemetry::metrics_snapshot::MetricsFrame;
900        use crate::telemetry::routing::RoutingDecision;
901        use crate::telemetry::transport_state::TransportStateEvent;
902        vec![
903            MetricsFrame::NAME,
904            TransportStateEvent::NAME,
905            RoutingDecision::NAME,
906            DeviceCapabilitySnapshot::NAME,
907        ]
908    }
909
910    /// Real implementation-level uniqueness check.
911    ///
912    /// Feeds every exemplar through [`Event::telemetry_name`] and
913    /// [`MlsLifecycleEvent::telemetry_name`] and asserts the produced names
914    /// are unique. Unlike a catalogue-only uniqueness check, this catches
915    /// the case where two match arms in the production mapping accidentally
916    /// produce the same string — a silent merge at the sink.
917    #[test]
918    fn impl_telemetry_names_are_unique() {
919        let mut names: Vec<&'static str> = Vec::new();
920        for e in event_exemplars() {
921            names.push(e.telemetry_name());
922        }
923        for e in mls_lifecycle_exemplars() {
924            names.push(e.telemetry_name());
925        }
926        names.extend(simple_record_names());
927        let unique: HashSet<&&'static str> = names.iter().collect();
928        assert_eq!(
929            unique.len(),
930            names.len(),
931            "two variants produced the same telemetry_name() — distinct \
932             events would silently merge at the sink. Names: {names:?}",
933        );
934    }
935
936    /// Guards against drift between the catalogue and the implementation.
937    ///
938    /// If a new variant is added without a catalogue entry, the catalogue
939    /// will be short one name. If a catalogue entry is left behind after a
940    /// variant rename, the catalogue will carry a stale name. Either failure
941    /// mode is caught here.
942    #[test]
943    fn impl_telemetry_names_match_catalogue() {
944        let mut impl_names: Vec<&'static str> = Vec::new();
945        for e in event_exemplars() {
946            impl_names.push(e.telemetry_name());
947        }
948        for e in mls_lifecycle_exemplars() {
949            impl_names.push(e.telemetry_name());
950        }
951        impl_names.extend(simple_record_names());
952
953        let impl_set: HashSet<&&'static str> = impl_names.iter().collect();
954        let catalogue_set: HashSet<&&str> = ALL_TELEMETRY_NAMES.iter().collect();
955
956        let missing_from_catalogue: Vec<&&'static str> = impl_set
957            .iter()
958            .copied()
959            .filter(|n| !ALL_TELEMETRY_NAMES.contains(*n))
960            .collect();
961        assert!(
962            missing_from_catalogue.is_empty(),
963            "implementation produces names not in ALL_TELEMETRY_NAMES: \
964             {missing_from_catalogue:?}. Update the catalogue.",
965        );
966
967        let stale_in_catalogue: Vec<&&str> = catalogue_set
968            .iter()
969            .copied()
970            .filter(|n| !impl_names.iter().any(|produced| produced == *n))
971            .collect();
972        assert!(
973            stale_in_catalogue.is_empty(),
974            "catalogue carries names the implementation does not produce: \
975             {stale_in_catalogue:?}. Remove from the catalogue or restore \
976             the match arm.",
977        );
978
979        assert_eq!(
980            impl_names.len(),
981            ALL_TELEMETRY_NAMES.len(),
982            "catalogue length ({}) differs from implementation variant \
983             count ({})",
984            ALL_TELEMETRY_NAMES.len(),
985            impl_names.len(),
986        );
987    }
988
989    /// Sanity check on the catalogue itself — duplicate entries would mask
990    /// drift against the implementation (two catalogue entries colliding
991    /// with a single implementation name would still pass the equality
992    /// check above). Kept alongside the implementation check so both
993    /// invariants are locked in.
994    #[test]
995    fn catalogue_entries_are_unique() {
996        let set: HashSet<&&str> = ALL_TELEMETRY_NAMES.iter().collect();
997        assert_eq!(
998            set.len(),
999            ALL_TELEMETRY_NAMES.len(),
1000            "duplicate entry in ALL_TELEMETRY_NAMES",
1001        );
1002    }
1003
1004    #[test]
1005    fn all_telemetry_names_match_grammar() {
1006        for name in ALL_TELEMETRY_NAMES {
1007            assert!(
1008                is_well_formed(name),
1009                "telemetry name '{name}' does not match snake.dot.case grammar",
1010            );
1011        }
1012    }
1013
1014    #[test]
1015    fn grammar_checker_rejects_malformed_names() {
1016        assert!(!is_well_formed(""));
1017        assert!(!is_well_formed("flat"));
1018        assert!(!is_well_formed(".leading"));
1019        assert!(!is_well_formed("trailing."));
1020        assert!(!is_well_formed("double..dot"));
1021        assert!(!is_well_formed("Upper.Case"));
1022        assert!(!is_well_formed("has-dash.segment"));
1023        assert!(is_well_formed("ok.name"));
1024        assert!(is_well_formed("a.b.c"));
1025        assert!(is_well_formed("with_underscore.segment"));
1026        assert!(is_well_formed("has_digit1.seg2"));
1027    }
1028}