offline-protocol 0.21.0

Main protocol engine for the Offline Protocol SDK
Documentation
//! Runtime telemetry configuration.
//!
//! [`TelemetryConfig`] carries the knobs consumed by the emit paths wired in
//! follow-up work. The struct is `#[non_exhaustive]` so new knobs can be added
//! without a breaking-change bump; construct via [`TelemetryConfig::default`]
//! and the `with_*` builder methods.

use std::fmt;
use std::time::Duration;

/// Verbosity tier for MLS lifecycle telemetry.
///
/// The SDK emits MLS events at or below the configured tier. Apps that want
/// zero MLS telemetry install `Off`, apps that want the standard operational
/// stream install `Lifecycle` (the default), and apps that want verbose
/// per-operation diagnostics install `Diagnostic`. `Off` suppresses both the
/// [`TelemetrySink`] fan-out and the legacy
/// [`crate::mls_observability::MlsEventEmitter`] path.
///
/// [`TelemetrySink`]: crate::telemetry::TelemetrySink
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum MlsVerbosity {
    /// Suppress all MLS lifecycle telemetry.
    Off,
    /// Emit the standard lifecycle stream: initialization, session ready,
    /// decryption failures, session missing, encryption used.
    #[default]
    Lifecycle,
    /// Emit the full lifecycle stream plus additional per-operation
    /// diagnostics intended for local debugging.
    ///
    /// # Current behavior
    ///
    /// As of this release, `Diagnostic` is accepted by the configuration
    /// surface but produces the *same* event stream as [`Self::Lifecycle`]
    /// — the additional per-operation diagnostics are introduced alongside
    /// the extra emission sites they describe. Apps that select
    /// `Diagnostic` today are forward-compatible with those follow-ups;
    /// they will not need to reconfigure when the richer stream lands.
    Diagnostic,
}

/// Runtime configuration for the telemetry subsystem.
///
/// Constructed via [`TelemetryConfig::default`] (which locks in the
/// privacy-preserving defaults) and refined with the `with_*` builder
/// methods. Fields are crate-private; read them through the accessor
/// methods and mutate them through the builder methods — this keeps a
/// single idiom for configuration and prevents mid-run mutation of
/// identity-correlation parameters like the scrub secret (which would
/// invalidate every previously emitted opaque identifier).
///
/// The struct is `#[non_exhaustive]` to preserve room for future knobs.
#[non_exhaustive]
#[derive(Clone)]
pub struct TelemetryConfig {
    pub(crate) scrub_ids: bool,
    pub(crate) mls_verbosity: MlsVerbosity,
    pub(crate) metrics_cadence: Option<Duration>,
    pub(crate) scrub_secret: Option<[u8; 16]>,
    pub(crate) routing_diagnostic: bool,
    pub(crate) mls_sampling_bypass: bool,
}

impl fmt::Debug for TelemetryConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("TelemetryConfig")
            .field("scrub_ids", &self.scrub_ids)
            .field("mls_verbosity", &self.mls_verbosity)
            .field("metrics_cadence", &self.metrics_cadence)
            .field(
                "scrub_secret",
                &self.scrub_secret.as_ref().map(|_| "<redacted>"),
            )
            .field("routing_diagnostic", &self.routing_diagnostic)
            .field("mls_sampling_bypass", &self.mls_sampling_bypass)
            .finish()
    }
}

impl Default for TelemetryConfig {
    fn default() -> Self {
        Self {
            scrub_ids: true,
            mls_verbosity: MlsVerbosity::Lifecycle,
            metrics_cadence: Some(Duration::from_secs(5)),
            scrub_secret: None,
            routing_diagnostic: false,
            mls_sampling_bypass: false,
        }
    }
}

impl TelemetryConfig {
    /// Sets the identifier-scrubbing flag.
    ///
    /// When `true` (the default), long-lived pseudonymous identifiers
    /// (`peer_id`, `user_id`, `app_id`, `group_id`) are hashed before
    /// crossing the telemetry sink boundary — third-party sinks (analytics,
    /// crash reporters) must not receive raw identifiers by default.
    pub fn with_scrub_ids(mut self, scrub_ids: bool) -> Self {
        self.scrub_ids = scrub_ids;
        self
    }

    /// Sets the MLS lifecycle verbosity tier.
    pub fn with_mls_verbosity(mut self, mls_verbosity: MlsVerbosity) -> Self {
        self.mls_verbosity = mls_verbosity;
        self
    }

    /// Sets the cadence for periodic `MetricsSnapshot` emission. `None`
    /// disables periodic emission entirely (apps fall back to pull-based
    /// access via `TransportManager::metrics()`).
    pub fn with_metrics_cadence(mut self, cadence: Option<Duration>) -> Self {
        self.metrics_cadence = cadence;
        self
    }

    /// Sets the opaque-identifier hashing secret.
    ///
    /// Supplying `Some(secret)` pins an explicit, caller-owned secret and
    /// always takes precedence over the SDK-managed fallback below.
    ///
    /// Supplying `None` (the default) leaves the secret unset in the config.
    /// In that case the SDK manages the fallback for you: the first time
    /// secure storage is provided via `OfflineProtocol::initialize_mls`, the
    /// SDK loads — or, on first run, generates and persists — a stable
    /// **per-install** secret. This makes
    /// opaque identifiers survive process restarts, so backend telemetry can
    /// count distinct devices across sessions. Until storage is available (or
    /// if storage is never provided), the SDK uses a random per-instance
    /// secret derived at protocol construction.
    ///
    /// Precedence: explicit config secret > SDK-managed persistent secret >
    /// random per-instance secret.
    ///
    /// **Do not share the secret across tenants, and do not rotate it
    /// mid-run** — rotation invalidates every previously emitted opaque
    /// identifier and breaks correlation on the receiving side. For the
    /// SDK-managed fallback this means: do not back two tenants with the same
    /// storage, and do not clear the `scrub_secret` storage entry within a
    /// measurement window.
    pub fn with_scrub_secret(mut self, secret: Option<[u8; 16]>) -> Self {
        self.scrub_secret = secret;
        self
    }

    /// Returns whether identifier scrubbing is enabled.
    pub fn scrub_ids(&self) -> bool {
        self.scrub_ids
    }

    /// Returns the configured MLS lifecycle verbosity tier.
    pub fn mls_verbosity(&self) -> MlsVerbosity {
        self.mls_verbosity
    }

    /// Returns the configured periodic metrics-snapshot cadence.
    pub fn metrics_cadence(&self) -> Option<Duration> {
        self.metrics_cadence
    }

    /// Sets whether `RoutingDecision` records should carry the full
    /// per-transport score breakdown.
    ///
    /// When `true`, every emitted
    /// [`crate::telemetry::RoutingDecision::scores`] is populated with the
    /// seven-factor [`offline_protocol_router::TransportScore`] for each
    /// ranked transport. When `false` (the default), `scores` is empty —
    /// the hot path avoids the per-emission vector allocation and
    /// `Diagnostic`-tier consumers simply see an empty vector.
    ///
    /// Leaf identifiers are not affected: this knob only gates the
    /// scoring-factor detail, not identity fields.
    pub fn with_routing_diagnostic(mut self, routing_diagnostic: bool) -> Self {
        self.routing_diagnostic = routing_diagnostic;
        self
    }

    /// Returns whether routing records should carry diagnostic score detail.
    pub fn routing_diagnostic(&self) -> bool {
        self.routing_diagnostic
    }

    /// Opts the installed sink out of MLS lifecycle event rate limiting.
    ///
    /// By default (`false`), high-volume MLS lifecycle events
    /// (`mls.decryption_failed`, `mls.session_missing`) pass through a
    /// best-effort fixed-window limiter that caps emission per peer+kind. The
    /// cap protects naïve sinks from event floods, but it also clips genuine
    /// failure spikes to the per-window ceiling — an aggregating backend
    /// cannot tell a real burst of N failures apart from the cap.
    ///
    /// Setting this to `true` disables that limiter so a **telemetry-grade**
    /// sink receives every MLS lifecycle event un-sampled, and aggregate
    /// counts reflect reality. Only enable it for sinks that apply their own
    /// backpressure (for example, pushing onto a bounded channel and draining
    /// on a background task per the [`TelemetrySink`] contract) — an
    /// unbuffered sink that does real work inline can be overwhelmed by an
    /// un-capped failure spike.
    ///
    /// This knob only affects MLS-event rate limiting; it does not change
    /// identifier scrubbing, verbosity, or any other field.
    ///
    /// [`TelemetrySink`]: crate::telemetry::TelemetrySink
    pub fn with_mls_sampling_bypass(mut self, mls_sampling_bypass: bool) -> Self {
        self.mls_sampling_bypass = mls_sampling_bypass;
        self
    }

    /// Returns whether MLS lifecycle event rate limiting is bypassed.
    pub fn mls_sampling_bypass(&self) -> bool {
        self.mls_sampling_bypass
    }

    /// Returns the configured opaque-identifier hashing secret, if any.
    ///
    /// Crate-private: the secret feeds `Scrubber` construction and is not
    /// part of the external reader surface — the Debug redaction on this
    /// struct would otherwise be defeated by a public byte-level accessor.
    ///
    /// Currently only consumed by `Scrubber::from_config` (scaffolding for
    /// the emission-wiring follow-up).
    #[allow(dead_code)]
    pub(crate) fn scrub_secret(&self) -> Option<[u8; 16]> {
        self.scrub_secret
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_locks_in_privacy_preserving_knobs() {
        let cfg = TelemetryConfig::default();
        assert!(cfg.scrub_ids());
        assert_eq!(cfg.mls_verbosity(), MlsVerbosity::Lifecycle);
        assert_eq!(cfg.metrics_cadence(), Some(Duration::from_secs(5)));
        assert!(cfg.scrub_secret().is_none());
        assert!(!cfg.routing_diagnostic());
        assert!(!cfg.mls_sampling_bypass());
    }

    #[test]
    fn builders_override_defaults() {
        let cfg = TelemetryConfig::default()
            .with_scrub_ids(false)
            .with_mls_verbosity(MlsVerbosity::Off)
            .with_metrics_cadence(None)
            .with_scrub_secret(Some([7; 16]))
            .with_routing_diagnostic(true)
            .with_mls_sampling_bypass(true);
        assert!(!cfg.scrub_ids());
        assert_eq!(cfg.mls_verbosity(), MlsVerbosity::Off);
        assert!(cfg.metrics_cadence().is_none());
        assert_eq!(cfg.scrub_secret(), Some([7; 16]));
        assert!(cfg.routing_diagnostic());
        assert!(cfg.mls_sampling_bypass());
    }

    #[test]
    fn metrics_cadence_is_typed_duration() {
        let cfg = TelemetryConfig::default().with_metrics_cadence(Some(Duration::from_millis(250)));
        assert_eq!(cfg.metrics_cadence(), Some(Duration::from_millis(250)));
    }

    #[test]
    fn debug_redacts_scrub_secret() {
        let cfg = TelemetryConfig::default().with_scrub_secret(Some([0xAB; 16]));
        let rendered = format!("{cfg:?}");
        assert!(
            rendered.contains("<redacted>"),
            "expected redaction marker, got {rendered}"
        );
        assert!(
            !rendered.contains("ab, ab") && !rendered.contains("171, 171"),
            "scrub_secret bytes leaked into Debug output: {rendered}"
        );
    }

    #[test]
    fn debug_shows_none_when_no_secret() {
        let cfg = TelemetryConfig::default();
        let rendered = format!("{cfg:?}");
        assert!(rendered.contains("scrub_secret: None"), "got {rendered}");
    }
}