Skip to main content

offline_protocol/telemetry/
config.rs

1//! Runtime telemetry configuration.
2//!
3//! [`TelemetryConfig`] carries the knobs consumed by the emit paths wired in
4//! follow-up work. The struct is `#[non_exhaustive]` so new knobs can be added
5//! without a breaking-change bump; construct via [`TelemetryConfig::default`]
6//! and the `with_*` builder methods.
7
8use std::fmt;
9use std::time::Duration;
10
11/// Verbosity tier for MLS lifecycle telemetry.
12///
13/// The SDK emits MLS events at or below the configured tier. Apps that want
14/// zero MLS telemetry install `Off`, apps that want the standard operational
15/// stream install `Lifecycle` (the default), and apps that want verbose
16/// per-operation diagnostics install `Diagnostic`. `Off` suppresses both the
17/// [`TelemetrySink`] fan-out and the legacy
18/// [`crate::mls_observability::MlsEventEmitter`] path.
19///
20/// [`TelemetrySink`]: crate::telemetry::TelemetrySink
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
22pub enum MlsVerbosity {
23    /// Suppress all MLS lifecycle telemetry.
24    Off,
25    /// Emit the standard lifecycle stream: initialization, session ready,
26    /// decryption failures, session missing, encryption used.
27    #[default]
28    Lifecycle,
29    /// Emit the full lifecycle stream plus additional per-operation
30    /// diagnostics intended for local debugging.
31    ///
32    /// # Current behavior
33    ///
34    /// As of this release, `Diagnostic` is accepted by the configuration
35    /// surface but produces the *same* event stream as [`Self::Lifecycle`]
36    /// — the additional per-operation diagnostics are introduced alongside
37    /// the extra emission sites they describe. Apps that select
38    /// `Diagnostic` today are forward-compatible with those follow-ups;
39    /// they will not need to reconfigure when the richer stream lands.
40    Diagnostic,
41}
42
43/// Runtime configuration for the telemetry subsystem.
44///
45/// Constructed via [`TelemetryConfig::default`] (which locks in the
46/// privacy-preserving defaults) and refined with the `with_*` builder
47/// methods. Fields are crate-private; read them through the accessor
48/// methods and mutate them through the builder methods — this keeps a
49/// single idiom for configuration and prevents mid-run mutation of
50/// identity-correlation parameters like the scrub secret (which would
51/// invalidate every previously emitted opaque identifier).
52///
53/// The struct is `#[non_exhaustive]` to preserve room for future knobs.
54#[non_exhaustive]
55#[derive(Clone)]
56pub struct TelemetryConfig {
57    pub(crate) scrub_ids: bool,
58    pub(crate) mls_verbosity: MlsVerbosity,
59    pub(crate) metrics_cadence: Option<Duration>,
60    pub(crate) scrub_secret: Option<[u8; 16]>,
61    pub(crate) routing_diagnostic: bool,
62    pub(crate) mls_sampling_bypass: bool,
63}
64
65impl fmt::Debug for TelemetryConfig {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.debug_struct("TelemetryConfig")
68            .field("scrub_ids", &self.scrub_ids)
69            .field("mls_verbosity", &self.mls_verbosity)
70            .field("metrics_cadence", &self.metrics_cadence)
71            .field(
72                "scrub_secret",
73                &self.scrub_secret.as_ref().map(|_| "<redacted>"),
74            )
75            .field("routing_diagnostic", &self.routing_diagnostic)
76            .field("mls_sampling_bypass", &self.mls_sampling_bypass)
77            .finish()
78    }
79}
80
81impl Default for TelemetryConfig {
82    fn default() -> Self {
83        Self {
84            scrub_ids: true,
85            mls_verbosity: MlsVerbosity::Lifecycle,
86            metrics_cadence: Some(Duration::from_secs(5)),
87            scrub_secret: None,
88            routing_diagnostic: false,
89            mls_sampling_bypass: false,
90        }
91    }
92}
93
94impl TelemetryConfig {
95    /// Sets the identifier-scrubbing flag.
96    ///
97    /// When `true` (the default), long-lived pseudonymous identifiers
98    /// (`peer_id`, `user_id`, `app_id`, `group_id`) are hashed before
99    /// crossing the telemetry sink boundary — third-party sinks (analytics,
100    /// crash reporters) must not receive raw identifiers by default.
101    pub fn with_scrub_ids(mut self, scrub_ids: bool) -> Self {
102        self.scrub_ids = scrub_ids;
103        self
104    }
105
106    /// Sets the MLS lifecycle verbosity tier.
107    pub fn with_mls_verbosity(mut self, mls_verbosity: MlsVerbosity) -> Self {
108        self.mls_verbosity = mls_verbosity;
109        self
110    }
111
112    /// Sets the cadence for periodic `MetricsSnapshot` emission. `None`
113    /// disables periodic emission entirely (apps fall back to pull-based
114    /// access via `TransportManager::metrics()`).
115    pub fn with_metrics_cadence(mut self, cadence: Option<Duration>) -> Self {
116        self.metrics_cadence = cadence;
117        self
118    }
119
120    /// Sets the opaque-identifier hashing secret.
121    ///
122    /// Supplying `Some(secret)` pins an explicit, caller-owned secret and
123    /// always takes precedence over the SDK-managed fallback below.
124    ///
125    /// Supplying `None` (the default) leaves the secret unset in the config.
126    /// In that case the SDK manages the fallback for you: the first time
127    /// secure storage is provided via `OfflineProtocol::initialize_mls`, the
128    /// SDK loads — or, on first run, generates and persists — a stable
129    /// **per-install** secret. This makes
130    /// opaque identifiers survive process restarts, so backend telemetry can
131    /// count distinct devices across sessions. Until storage is available (or
132    /// if storage is never provided), the SDK uses a random per-instance
133    /// secret derived at protocol construction.
134    ///
135    /// Precedence: explicit config secret > SDK-managed persistent secret >
136    /// random per-instance secret.
137    ///
138    /// **Do not share the secret across tenants, and do not rotate it
139    /// mid-run** — rotation invalidates every previously emitted opaque
140    /// identifier and breaks correlation on the receiving side. For the
141    /// SDK-managed fallback this means: do not back two tenants with the same
142    /// storage, and do not clear the `scrub_secret` storage entry within a
143    /// measurement window.
144    pub fn with_scrub_secret(mut self, secret: Option<[u8; 16]>) -> Self {
145        self.scrub_secret = secret;
146        self
147    }
148
149    /// Returns whether identifier scrubbing is enabled.
150    pub fn scrub_ids(&self) -> bool {
151        self.scrub_ids
152    }
153
154    /// Returns the configured MLS lifecycle verbosity tier.
155    pub fn mls_verbosity(&self) -> MlsVerbosity {
156        self.mls_verbosity
157    }
158
159    /// Returns the configured periodic metrics-snapshot cadence.
160    pub fn metrics_cadence(&self) -> Option<Duration> {
161        self.metrics_cadence
162    }
163
164    /// Sets whether `RoutingDecision` records should carry the full
165    /// per-transport score breakdown.
166    ///
167    /// When `true`, every emitted
168    /// [`crate::telemetry::RoutingDecision::scores`] is populated with the
169    /// seven-factor [`offline_protocol_router::TransportScore`] for each
170    /// ranked transport. When `false` (the default), `scores` is empty —
171    /// the hot path avoids the per-emission vector allocation and
172    /// `Diagnostic`-tier consumers simply see an empty vector.
173    ///
174    /// Leaf identifiers are not affected: this knob only gates the
175    /// scoring-factor detail, not identity fields.
176    pub fn with_routing_diagnostic(mut self, routing_diagnostic: bool) -> Self {
177        self.routing_diagnostic = routing_diagnostic;
178        self
179    }
180
181    /// Returns whether routing records should carry diagnostic score detail.
182    pub fn routing_diagnostic(&self) -> bool {
183        self.routing_diagnostic
184    }
185
186    /// Opts the installed sink out of MLS lifecycle event rate limiting.
187    ///
188    /// By default (`false`), high-volume MLS lifecycle events
189    /// (`mls.decryption_failed`, `mls.session_missing`) pass through a
190    /// best-effort fixed-window limiter that caps emission per peer+kind. The
191    /// cap protects naïve sinks from event floods, but it also clips genuine
192    /// failure spikes to the per-window ceiling — an aggregating backend
193    /// cannot tell a real burst of N failures apart from the cap.
194    ///
195    /// Setting this to `true` disables that limiter so a **telemetry-grade**
196    /// sink receives every MLS lifecycle event un-sampled, and aggregate
197    /// counts reflect reality. Only enable it for sinks that apply their own
198    /// backpressure (for example, pushing onto a bounded channel and draining
199    /// on a background task per the [`TelemetrySink`] contract) — an
200    /// unbuffered sink that does real work inline can be overwhelmed by an
201    /// un-capped failure spike.
202    ///
203    /// This knob only affects MLS-event rate limiting; it does not change
204    /// identifier scrubbing, verbosity, or any other field.
205    ///
206    /// [`TelemetrySink`]: crate::telemetry::TelemetrySink
207    pub fn with_mls_sampling_bypass(mut self, mls_sampling_bypass: bool) -> Self {
208        self.mls_sampling_bypass = mls_sampling_bypass;
209        self
210    }
211
212    /// Returns whether MLS lifecycle event rate limiting is bypassed.
213    pub fn mls_sampling_bypass(&self) -> bool {
214        self.mls_sampling_bypass
215    }
216
217    /// Returns the configured opaque-identifier hashing secret, if any.
218    ///
219    /// Crate-private: the secret feeds `Scrubber` construction and is not
220    /// part of the external reader surface — the Debug redaction on this
221    /// struct would otherwise be defeated by a public byte-level accessor.
222    ///
223    /// Currently only consumed by `Scrubber::from_config` (scaffolding for
224    /// the emission-wiring follow-up).
225    #[allow(dead_code)]
226    pub(crate) fn scrub_secret(&self) -> Option<[u8; 16]> {
227        self.scrub_secret
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn default_locks_in_privacy_preserving_knobs() {
237        let cfg = TelemetryConfig::default();
238        assert!(cfg.scrub_ids());
239        assert_eq!(cfg.mls_verbosity(), MlsVerbosity::Lifecycle);
240        assert_eq!(cfg.metrics_cadence(), Some(Duration::from_secs(5)));
241        assert!(cfg.scrub_secret().is_none());
242        assert!(!cfg.routing_diagnostic());
243        assert!(!cfg.mls_sampling_bypass());
244    }
245
246    #[test]
247    fn builders_override_defaults() {
248        let cfg = TelemetryConfig::default()
249            .with_scrub_ids(false)
250            .with_mls_verbosity(MlsVerbosity::Off)
251            .with_metrics_cadence(None)
252            .with_scrub_secret(Some([7; 16]))
253            .with_routing_diagnostic(true)
254            .with_mls_sampling_bypass(true);
255        assert!(!cfg.scrub_ids());
256        assert_eq!(cfg.mls_verbosity(), MlsVerbosity::Off);
257        assert!(cfg.metrics_cadence().is_none());
258        assert_eq!(cfg.scrub_secret(), Some([7; 16]));
259        assert!(cfg.routing_diagnostic());
260        assert!(cfg.mls_sampling_bypass());
261    }
262
263    #[test]
264    fn metrics_cadence_is_typed_duration() {
265        let cfg = TelemetryConfig::default().with_metrics_cadence(Some(Duration::from_millis(250)));
266        assert_eq!(cfg.metrics_cadence(), Some(Duration::from_millis(250)));
267    }
268
269    #[test]
270    fn debug_redacts_scrub_secret() {
271        let cfg = TelemetryConfig::default().with_scrub_secret(Some([0xAB; 16]));
272        let rendered = format!("{cfg:?}");
273        assert!(
274            rendered.contains("<redacted>"),
275            "expected redaction marker, got {rendered}"
276        );
277        assert!(
278            !rendered.contains("ab, ab") && !rendered.contains("171, 171"),
279            "scrub_secret bytes leaked into Debug output: {rendered}"
280        );
281    }
282
283    #[test]
284    fn debug_shows_none_when_no_secret() {
285        let cfg = TelemetryConfig::default();
286        let rendered = format!("{cfg:?}");
287        assert!(rendered.contains("scrub_secret: None"), "got {rendered}");
288    }
289}