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/// 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}