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/// Default background flush cadence.
44pub const DEFAULT_FLUSH_INTERVAL: Duration = Duration::from_secs(30);
45/// Default size trigger for an early flush, and the largest batch cut.
46pub const DEFAULT_MAX_BATCH_BYTES: usize = 256 * 1024;
47/// Largest batch the configuration accepts. The ingest's body limit sits
48/// above it; a batch that cannot fit is dropped, never split.
49pub const MAX_BATCH_BYTES_LIMIT: usize = 1024 * 1024;
50/// Default ring-buffer capacity; drops oldest beyond it.
51pub const DEFAULT_MAX_BUFFERED_RECORDS: usize = 4096;
52/// Shortest flush cadence the configuration accepts.
53pub const MIN_FLUSH_INTERVAL: Duration = Duration::from_secs(1);
54
55/// Runtime configuration for the telemetry subsystem.
56///
57/// Constructed via [`TelemetryConfig::default`] (which locks in the
58/// privacy-preserving defaults) and refined with the `with_*` builder
59/// methods. `enable_telemetry` additionally needs an `api_key` and an
60/// `app_id`, both issued by the developer portal; see
61/// [`TelemetryConfig::validate`] for what it refuses. Fields are crate-private; read them through the accessor
62/// methods and mutate them through the builder methods — this keeps a
63/// single idiom for configuration and prevents mid-run mutation of
64/// identity-correlation parameters like the scrub secret (which would
65/// invalidate every previously emitted opaque identifier).
66///
67/// The struct is `#[non_exhaustive]` to preserve room for future knobs.
68#[non_exhaustive]
69#[derive(Clone)]
70pub struct TelemetryConfig {
71 pub(crate) scrub_ids: bool,
72 pub(crate) mls_verbosity: MlsVerbosity,
73 pub(crate) metrics_cadence: Option<Duration>,
74 pub(crate) scrub_secret: Option<[u8; 16]>,
75 pub(crate) routing_diagnostic: bool,
76 pub(crate) mls_sampling_bypass: bool,
77 pub(crate) api_key: Option<String>,
78 pub(crate) app_id: Option<String>,
79 pub(crate) app_version: String,
80 pub(crate) debug: bool,
81 pub(crate) flush_interval: Duration,
82 pub(crate) max_batch_bytes: usize,
83 pub(crate) max_buffered_records: usize,
84 pub(crate) include_device_id: bool,
85}
86
87impl fmt::Debug for TelemetryConfig {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 f.debug_struct("TelemetryConfig")
90 .field("scrub_ids", &self.scrub_ids)
91 .field("mls_verbosity", &self.mls_verbosity)
92 .field("metrics_cadence", &self.metrics_cadence)
93 .field(
94 "scrub_secret",
95 &self.scrub_secret.as_ref().map(|_| "<redacted>"),
96 )
97 .field("routing_diagnostic", &self.routing_diagnostic)
98 .field("mls_sampling_bypass", &self.mls_sampling_bypass)
99 .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
100 .field("app_id", &self.app_id)
101 .field("app_version", &self.app_version)
102 .field("debug", &self.debug)
103 .field("flush_interval", &self.flush_interval)
104 .field("max_batch_bytes", &self.max_batch_bytes)
105 .field("max_buffered_records", &self.max_buffered_records)
106 .field("include_device_id", &self.include_device_id)
107 .finish()
108 }
109}
110
111impl Default for TelemetryConfig {
112 fn default() -> Self {
113 Self {
114 scrub_ids: true,
115 mls_verbosity: MlsVerbosity::Lifecycle,
116 metrics_cadence: Some(Duration::from_secs(5)),
117 scrub_secret: None,
118 routing_diagnostic: false,
119 mls_sampling_bypass: false,
120 api_key: None,
121 app_id: None,
122 app_version: "0.0.0".to_string(),
123 debug: false,
124 flush_interval: DEFAULT_FLUSH_INTERVAL,
125 max_batch_bytes: DEFAULT_MAX_BATCH_BYTES,
126 max_buffered_records: DEFAULT_MAX_BUFFERED_RECORDS,
127 include_device_id: false,
128 }
129 }
130}
131
132impl TelemetryConfig {
133 /// Sets the identifier-scrubbing flag.
134 ///
135 /// When `true` (the default), the engine hashes the peer and group ids it
136 /// stamps on MLS lifecycle records with the scrub secret (see
137 /// [`Self::with_scrub_secret`]). The telemetry pipe reads the peer id only
138 /// to pair a handshake's start with its end, and no identifier has a wire
139 /// field, so the flag changes what the pipe holds in memory and never what
140 /// is uploaded. It does not apply to `on_event`, which delivers every
141 /// event with its identifiers as they are.
142 pub fn with_scrub_ids(mut self, scrub_ids: bool) -> Self {
143 self.scrub_ids = scrub_ids;
144 self
145 }
146
147 /// Sets the MLS lifecycle verbosity tier.
148 pub fn with_mls_verbosity(mut self, mls_verbosity: MlsVerbosity) -> Self {
149 self.mls_verbosity = mls_verbosity;
150 self
151 }
152
153 /// Sets the cadence for periodic `MetricsSnapshot` emission. `None`
154 /// disables periodic emission entirely (apps fall back to pull-based
155 /// access via `TransportManager::metrics()`).
156 pub fn with_metrics_cadence(mut self, cadence: Option<Duration>) -> Self {
157 self.metrics_cadence = cadence;
158 self
159 }
160
161 /// Sets the opaque-identifier hashing secret.
162 ///
163 /// Supplying `Some(secret)` pins an explicit, caller-owned secret and
164 /// always takes precedence over the SDK-managed fallback below.
165 ///
166 /// Supplying `None` (the default) leaves the secret unset in the config.
167 /// In that case the SDK manages the fallback for you: the first time
168 /// secure storage is provided via `OfflineProtocol::initialize_mls`, the
169 /// SDK loads — or, on first run, generates and persists — a stable
170 /// **per-install** secret. This makes
171 /// opaque identifiers survive process restarts, so backend telemetry can
172 /// count distinct devices across sessions. Until storage is available (or
173 /// if storage is never provided), the SDK uses a random per-instance
174 /// secret derived at protocol construction.
175 ///
176 /// Precedence: explicit config secret > SDK-managed persistent secret >
177 /// random per-instance secret.
178 ///
179 /// **Do not share the secret across tenants, and do not rotate it
180 /// mid-run** — rotation invalidates every previously emitted opaque
181 /// identifier and breaks correlation on the receiving side. For the
182 /// SDK-managed fallback this means: do not back two tenants with the same
183 /// storage, and do not clear the `scrub_secret` storage entry within a
184 /// measurement window.
185 pub fn with_scrub_secret(mut self, secret: Option<[u8; 16]>) -> Self {
186 self.scrub_secret = secret;
187 self
188 }
189
190 /// Returns whether identifier scrubbing is enabled.
191 pub fn scrub_ids(&self) -> bool {
192 self.scrub_ids
193 }
194
195 /// Returns the configured MLS lifecycle verbosity tier.
196 pub fn mls_verbosity(&self) -> MlsVerbosity {
197 self.mls_verbosity
198 }
199
200 /// Returns the configured periodic metrics-snapshot cadence.
201 pub fn metrics_cadence(&self) -> Option<Duration> {
202 self.metrics_cadence
203 }
204
205 /// Sets whether `RoutingDecision` records should carry the full
206 /// per-transport score breakdown.
207 ///
208 /// When `true`, every emitted
209 /// [`crate::telemetry::RoutingDecision::scores`] is populated with the
210 /// seven-factor [`offline_protocol_router::TransportScore`] for each
211 /// ranked transport. When `false` (the default), `scores` is empty —
212 /// the hot path avoids the per-emission vector allocation and
213 /// `Diagnostic`-tier consumers simply see an empty vector.
214 ///
215 /// Leaf identifiers are not affected: this knob only gates the
216 /// scoring-factor detail, not identity fields.
217 pub fn with_routing_diagnostic(mut self, routing_diagnostic: bool) -> Self {
218 self.routing_diagnostic = routing_diagnostic;
219 self
220 }
221
222 /// Returns whether routing records should carry diagnostic score detail.
223 pub fn routing_diagnostic(&self) -> bool {
224 self.routing_diagnostic
225 }
226
227 /// Opts MLS lifecycle telemetry out of rate limiting.
228 ///
229 /// By default (`false`), high-volume MLS lifecycle events
230 /// (`mls.decryption_failed`, `mls.session_missing`) pass through a
231 /// best-effort fixed-window limiter that caps emission per peer+kind. The
232 /// cap bounds what an event flood costs, but it also clips genuine
233 /// failure spikes to the per-window ceiling — an aggregating backend
234 /// cannot tell a real burst of N failures apart from the cap.
235 ///
236 /// Setting this to `true` disables that limiter, so every MLS lifecycle
237 /// event reaches the telemetry pipe un-sampled and aggregate counts
238 /// reflect reality, at the cost of everything an un-capped failure spike
239 /// puts through it. Leave it off unless exact failure counts are needed.
240 ///
241 /// This knob only affects MLS-event rate limiting; it does not change
242 /// identifier scrubbing, verbosity, or any other field.
243 pub fn with_mls_sampling_bypass(mut self, mls_sampling_bypass: bool) -> Self {
244 self.mls_sampling_bypass = mls_sampling_bypass;
245 self
246 }
247
248 /// Returns whether MLS lifecycle event rate limiting is bypassed.
249 pub fn mls_sampling_bypass(&self) -> bool {
250 self.mls_sampling_bypass
251 }
252
253 /// Sets the API key the developer portal issued. Sent as a bearer token
254 /// on every upload; redacted from `Debug` output.
255 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
256 self.api_key = Some(api_key.into());
257 self
258 }
259
260 /// Sets the app id the key was issued against. The ingest cross-checks
261 /// the two and refuses a mismatch, which stops a key pasted into the
262 /// wrong app from misattributing its data.
263 pub fn with_app_id(mut self, app_id: impl Into<String>) -> Self {
264 self.app_id = Some(app_id.into());
265 self
266 }
267
268 /// Sets the application's own version string, stamped on every batch.
269 pub fn with_app_version(mut self, app_version: impl Into<String>) -> Self {
270 self.app_version = app_version.into();
271 self
272 }
273
274 /// Logs every wire event at `debug` under the pipe's `tracing` target,
275 /// so an adopter can see exactly what leaves the device.
276 pub fn with_debug(mut self, debug: bool) -> Self {
277 self.debug = debug;
278 self
279 }
280
281 /// Sets the background flush cadence. Not below [`MIN_FLUSH_INTERVAL`].
282 pub fn with_flush_interval(mut self, flush_interval: Duration) -> Self {
283 self.flush_interval = flush_interval;
284 self
285 }
286
287 /// Sets the size trigger for an early flush and the largest batch cut.
288 pub fn with_max_batch_bytes(mut self, max_batch_bytes: usize) -> Self {
289 self.max_batch_bytes = max_batch_bytes;
290 self
291 }
292
293 /// Sets the ring-buffer capacity.
294 pub fn with_max_buffered_records(mut self, max_buffered_records: usize) -> Self {
295 self.max_buffered_records = max_buffered_records;
296 self
297 }
298
299 /// Stamps the stable per-install id on every batch, so the ingest counts
300 /// distinct devices rather than distinct sessions.
301 ///
302 /// Off by default because it is a disclosure decision: a stable
303 /// per-install identifier is a "device or other ID" under Google Play's
304 /// definition and a `NSPrivacyCollectedDataTypeDeviceID` under Apple's.
305 /// See `docs/privacy.md`.
306 pub fn with_include_device_id(mut self, include_device_id: bool) -> Self {
307 self.include_device_id = include_device_id;
308 self
309 }
310
311 /// Returns the configured API key.
312 pub fn api_key(&self) -> Option<&str> {
313 self.api_key.as_deref()
314 }
315
316 /// Returns the configured app id.
317 pub fn app_id(&self) -> Option<&str> {
318 self.app_id.as_deref()
319 }
320
321 /// Returns the application version stamped on batches.
322 pub fn app_version(&self) -> &str {
323 &self.app_version
324 }
325
326 /// Returns whether the debug tap is on.
327 pub fn debug(&self) -> bool {
328 self.debug
329 }
330
331 /// Returns the background flush cadence.
332 pub fn flush_interval(&self) -> Duration {
333 self.flush_interval
334 }
335
336 /// Returns the size trigger and batch ceiling.
337 pub fn max_batch_bytes(&self) -> usize {
338 self.max_batch_bytes
339 }
340
341 /// Returns the ring-buffer capacity.
342 pub fn max_buffered_records(&self) -> usize {
343 self.max_buffered_records
344 }
345
346 /// Returns whether the per-install id is stamped on batches.
347 pub fn include_device_id(&self) -> bool {
348 self.include_device_id
349 }
350
351 /// Checks the fields `enable_telemetry` depends on, naming the first one
352 /// that fails.
353 ///
354 /// Refused: a missing or empty `api_key` or `app_id`, or one that is not
355 /// printable ASCII (a header value the ingest could never read, which
356 /// would otherwise be retried forever as a network error); an empty
357 /// `app_version`; a `max_batch_bytes` of zero or above
358 /// [`MAX_BATCH_BYTES_LIMIT`]; a `max_buffered_records` of zero; a
359 /// `flush_interval` below [`MIN_FLUSH_INTERVAL`], which is reported under
360 /// the name every binding gives it, `flush_interval_ms`.
361 pub fn validate(&self) -> Result<(), &'static str> {
362 fn header_safe(value: Option<&str>) -> bool {
363 value.is_some_and(|v| !v.is_empty() && v.bytes().all(|b| (0x20..=0x7e).contains(&b)))
364 }
365 if !header_safe(self.api_key.as_deref()) {
366 return Err("api_key");
367 }
368 if !header_safe(self.app_id.as_deref()) {
369 return Err("app_id");
370 }
371 if self.app_version.is_empty() {
372 return Err("app_version");
373 }
374 if self.max_batch_bytes == 0 || self.max_batch_bytes > MAX_BATCH_BYTES_LIMIT {
375 return Err("max_batch_bytes");
376 }
377 if self.max_buffered_records == 0 {
378 return Err("max_buffered_records");
379 }
380 if self.flush_interval < MIN_FLUSH_INTERVAL {
381 // Named for the field the caller set, not for the one this struct
382 // holds. The refusal is the only diagnostic an application gets,
383 // and every binding spells this `flushIntervalMs`; a message
384 // naming `flush_interval` sends the reader looking for a field
385 // their SDK does not have.
386 return Err("flush_interval_ms");
387 }
388 Ok(())
389 }
390
391 /// Returns the configured opaque-identifier hashing secret, if any.
392 ///
393 /// Crate-private: the secret feeds `Scrubber` construction and is not
394 /// part of the external reader surface — the Debug redaction on this
395 /// struct would otherwise be defeated by a public byte-level accessor.
396 ///
397 /// Currently only consumed by `Scrubber::from_config` (scaffolding for
398 /// the emission-wiring follow-up).
399 #[allow(dead_code)]
400 pub(crate) fn scrub_secret(&self) -> Option<[u8; 16]> {
401 self.scrub_secret
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
410 fn default_locks_in_privacy_preserving_knobs() {
411 let cfg = TelemetryConfig::default();
412 assert!(cfg.scrub_ids());
413 assert_eq!(cfg.mls_verbosity(), MlsVerbosity::Lifecycle);
414 assert_eq!(cfg.metrics_cadence(), Some(Duration::from_secs(5)));
415 assert!(cfg.scrub_secret().is_none());
416 assert!(!cfg.routing_diagnostic());
417 assert!(!cfg.mls_sampling_bypass());
418 assert!(cfg.api_key().is_none());
419 assert!(cfg.app_id().is_none());
420 assert_eq!(cfg.app_version(), "0.0.0");
421 assert!(!cfg.debug());
422 assert_eq!(cfg.flush_interval(), Duration::from_secs(30));
423 assert_eq!(cfg.max_batch_bytes(), 256 * 1024);
424 assert_eq!(cfg.max_buffered_records(), 4096);
425 assert!(
426 !cfg.include_device_id(),
427 "a stable id is a disclosure decision"
428 );
429 }
430
431 #[test]
432 fn validate_names_the_first_field_it_refuses() {
433 let valid = TelemetryConfig::default()
434 .with_api_key("mp_k")
435 .with_app_id("app_1 (prod)");
436 assert_eq!(valid.validate(), Ok(()));
437 assert_eq!(TelemetryConfig::default().validate(), Err("api_key"));
438 assert_eq!(valid.clone().with_api_key("").validate(), Err("api_key"));
439 assert_eq!(
440 valid.clone().with_api_key("k\u{e9}y").validate(),
441 Err("api_key")
442 );
443 assert_eq!(valid.clone().with_app_id("").validate(), Err("app_id"));
444 assert_eq!(valid.clone().with_app_id("app\n").validate(), Err("app_id"));
445 assert_eq!(
446 valid.clone().with_app_version("").validate(),
447 Err("app_version")
448 );
449 assert_eq!(
450 valid.clone().with_max_batch_bytes(0).validate(),
451 Err("max_batch_bytes")
452 );
453 assert_eq!(
454 valid
455 .clone()
456 .with_max_batch_bytes(MAX_BATCH_BYTES_LIMIT + 1)
457 .validate(),
458 Err("max_batch_bytes")
459 );
460 assert_eq!(
461 valid.clone().with_max_buffered_records(0).validate(),
462 Err("max_buffered_records")
463 );
464 assert_eq!(
465 valid
466 .clone()
467 .with_flush_interval(Duration::from_millis(999))
468 .validate(),
469 Err("flush_interval_ms")
470 );
471 }
472
473 #[test]
474 fn debug_redacts_the_api_key() {
475 let cfg = TelemetryConfig::default().with_api_key("mp_secret_key");
476 let rendered = format!("{cfg:?}");
477 assert!(!rendered.contains("mp_secret_key"), "{rendered}");
478 assert!(
479 rendered.contains("api_key: Some(\"<redacted>\")"),
480 "{rendered}"
481 );
482 }
483
484 #[test]
485 fn builders_override_defaults() {
486 let cfg = TelemetryConfig::default()
487 .with_scrub_ids(false)
488 .with_mls_verbosity(MlsVerbosity::Off)
489 .with_metrics_cadence(None)
490 .with_scrub_secret(Some([7; 16]))
491 .with_routing_diagnostic(true)
492 .with_mls_sampling_bypass(true);
493 assert!(!cfg.scrub_ids());
494 assert_eq!(cfg.mls_verbosity(), MlsVerbosity::Off);
495 assert!(cfg.metrics_cadence().is_none());
496 assert_eq!(cfg.scrub_secret(), Some([7; 16]));
497 assert!(cfg.routing_diagnostic());
498 assert!(cfg.mls_sampling_bypass());
499 }
500
501 #[test]
502 fn metrics_cadence_is_typed_duration() {
503 let cfg = TelemetryConfig::default().with_metrics_cadence(Some(Duration::from_millis(250)));
504 assert_eq!(cfg.metrics_cadence(), Some(Duration::from_millis(250)));
505 }
506
507 #[test]
508 fn debug_redacts_scrub_secret() {
509 let cfg = TelemetryConfig::default().with_scrub_secret(Some([0xAB; 16]));
510 let rendered = format!("{cfg:?}");
511 assert!(
512 rendered.contains("<redacted>"),
513 "expected redaction marker, got {rendered}"
514 );
515 assert!(
516 !rendered.contains("ab, ab") && !rendered.contains("171, 171"),
517 "scrub_secret bytes leaked into Debug output: {rendered}"
518 );
519 }
520
521 #[test]
522 fn debug_shows_none_when_no_secret() {
523 let cfg = TelemetryConfig::default();
524 let rendered = format!("{cfg:?}");
525 assert!(rendered.contains("scrub_secret: None"), "got {rendered}");
526 }
527}