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