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