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