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