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