offline_protocol/telemetry/sink.rs
1//! Cross-cutting telemetry sink trait.
2//!
3//! [`TelemetrySink`] is the single observer interface for all telemetry the
4//! SDK emits. Apps implement it (or install [`NoopTelemetrySink`]) and the SDK
5//! pushes every [`TelemetryRecord`] through it.
6//!
7//! Emit wiring lives in follow-up work; this module ships the types only.
8
9use super::record::TelemetryRecord;
10use crate::events::Event;
11
12/// Sink for structured telemetry records.
13///
14/// Implementations must be cheap when called from hot paths — the SDK does not
15/// buffer or rate-limit before dispatch beyond its own sampling layer, so
16/// expensive work (I/O, serialization to external formats) should be
17/// deferred to a background task owned by the implementor.
18///
19/// # Reentrancy and locking contract
20///
21/// The SDK may invoke [`TelemetrySink::emit`] while holding internal locks
22/// (most notably the shared-state mutex on the `Protocol` fan-out path).
23/// Implementations MUST NOT:
24///
25/// - block on slow I/O (network, disk, logging sinks that fsync), or
26/// - reenter SDK methods on the same `OfflineProtocol` instance.
27///
28/// A blocking `emit` stalls every protocol operation that needs the lock.
29/// Sinks that need to do real work should push the record onto a bounded
30/// channel and return immediately, leaving the heavy lifting to a
31/// background task owned by the implementor.
32///
33/// Implementations *should* also not panic, but the SDK defends against
34/// this: every internal dispatch to `emit` runs under `catch_unwind`, so a
35/// panicking sink is logged at `error!`, the offending record is dropped,
36/// and the protocol continues. A panicking sink therefore loses telemetry
37/// and degrades observability but does not corrupt SDK state. Panic
38/// isolation only applies under `panic = unwind` — release profiles that
39/// set `panic = abort` (including the `minisize` profile used for mobile)
40/// abort the process on any panic, including one from a sink.
41///
42/// # Record passing
43///
44/// The record is passed by reference so implementations that only read fields
45/// do not incur a clone. Implementations that need ownership (for example to
46/// forward the record to another thread) should call [`Clone::clone`]
47/// explicitly.
48pub trait TelemetrySink: Send + Sync {
49 /// Dispatches a single telemetry record to the sink.
50 fn emit(&self, record: &TelemetryRecord);
51
52 /// Offers a protocol event by reference, before it is scrubbed or
53 /// cloned into a [`TelemetryRecord`].
54 ///
55 /// A sink that returns `true` has consumed the event and the owned,
56 /// scrubbed record is never built; one that returns `false` (the
57 /// default) receives it through [`Self::emit`] as before. The telemetry
58 /// pipe takes this path: it collects ten of the engine's events, reads
59 /// no identifier from any of them, and would otherwise pay a clone of
60 /// every event it drops. A sink that takes this path must do its own
61 /// scrubbing, because the engine's has not run yet.
62 fn try_emit_protocol_event(&self, _event: &Event) -> bool {
63 false
64 }
65}
66
67/// Default sink that discards every record.
68///
69/// Used by the SDK when no telemetry sink has been installed. Proves the
70/// zero-cost baseline: the compiler inlines the empty body at every call site
71/// that can statically resolve the concrete type, and the indirect dispatch
72/// cost through `Arc<dyn TelemetrySink>` is a single virtual call that returns
73/// immediately.
74#[derive(Debug, Default, Clone, Copy)]
75pub struct NoopTelemetrySink;
76
77impl TelemetrySink for NoopTelemetrySink {
78 fn emit(&self, _record: &TelemetryRecord) {}
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84 use crate::mls_observability::{MlsLifecycleEvent, MlsOperationContext};
85 use std::sync::Arc;
86
87 #[test]
88 fn noop_sink_is_send_sync() {
89 fn assert_send_sync<T: Send + Sync>() {}
90 assert_send_sync::<NoopTelemetrySink>();
91 }
92
93 #[test]
94 fn trait_is_object_safe() {
95 let _: Arc<dyn TelemetrySink> = Arc::new(NoopTelemetrySink);
96 }
97
98 #[test]
99 fn noop_sink_accepts_records_without_panic() {
100 let sink = NoopTelemetrySink;
101 let record = TelemetryRecord::Mls(MlsLifecycleEvent::Initialized {
102 timestamp_ms: 0,
103 session_id: "s".into(),
104 group_id: None,
105 peer_id: None,
106 context: MlsOperationContext::Initialize,
107 error_category: None,
108 });
109 sink.emit(&record);
110 }
111}