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}