use std::fmt;
use std::time::Duration;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum MlsVerbosity {
Off,
#[default]
Lifecycle,
Diagnostic,
}
pub const DEFAULT_FLUSH_INTERVAL: Duration = Duration::from_secs(30);
pub const DEFAULT_MAX_BATCH_BYTES: usize = 256 * 1024;
pub const MAX_BATCH_BYTES_LIMIT: usize = 1024 * 1024;
pub const DEFAULT_MAX_BUFFERED_RECORDS: usize = 4096;
pub const MIN_FLUSH_INTERVAL: Duration = Duration::from_secs(1);
#[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,
pub(crate) api_key: Option<String>,
pub(crate) app_id: Option<String>,
pub(crate) app_version: String,
pub(crate) debug: bool,
pub(crate) flush_interval: Duration,
pub(crate) max_batch_bytes: usize,
pub(crate) max_buffered_records: usize,
pub(crate) include_device_id: 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)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("app_id", &self.app_id)
.field("app_version", &self.app_version)
.field("debug", &self.debug)
.field("flush_interval", &self.flush_interval)
.field("max_batch_bytes", &self.max_batch_bytes)
.field("max_buffered_records", &self.max_buffered_records)
.field("include_device_id", &self.include_device_id)
.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,
api_key: None,
app_id: None,
app_version: "0.0.0".to_string(),
debug: false,
flush_interval: DEFAULT_FLUSH_INTERVAL,
max_batch_bytes: DEFAULT_MAX_BATCH_BYTES,
max_buffered_records: DEFAULT_MAX_BUFFERED_RECORDS,
include_device_id: false,
}
}
}
impl TelemetryConfig {
pub fn with_scrub_ids(mut self, scrub_ids: bool) -> Self {
self.scrub_ids = scrub_ids;
self
}
pub fn with_mls_verbosity(mut self, mls_verbosity: MlsVerbosity) -> Self {
self.mls_verbosity = mls_verbosity;
self
}
pub fn with_metrics_cadence(mut self, cadence: Option<Duration>) -> Self {
self.metrics_cadence = cadence;
self
}
pub fn with_scrub_secret(mut self, secret: Option<[u8; 16]>) -> Self {
self.scrub_secret = secret;
self
}
pub fn scrub_ids(&self) -> bool {
self.scrub_ids
}
pub fn mls_verbosity(&self) -> MlsVerbosity {
self.mls_verbosity
}
pub fn metrics_cadence(&self) -> Option<Duration> {
self.metrics_cadence
}
pub fn with_routing_diagnostic(mut self, routing_diagnostic: bool) -> Self {
self.routing_diagnostic = routing_diagnostic;
self
}
pub fn routing_diagnostic(&self) -> bool {
self.routing_diagnostic
}
pub fn with_mls_sampling_bypass(mut self, mls_sampling_bypass: bool) -> Self {
self.mls_sampling_bypass = mls_sampling_bypass;
self
}
pub fn mls_sampling_bypass(&self) -> bool {
self.mls_sampling_bypass
}
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
pub fn with_app_id(mut self, app_id: impl Into<String>) -> Self {
self.app_id = Some(app_id.into());
self
}
pub fn with_app_version(mut self, app_version: impl Into<String>) -> Self {
self.app_version = app_version.into();
self
}
pub fn with_debug(mut self, debug: bool) -> Self {
self.debug = debug;
self
}
pub fn with_flush_interval(mut self, flush_interval: Duration) -> Self {
self.flush_interval = flush_interval;
self
}
pub fn with_max_batch_bytes(mut self, max_batch_bytes: usize) -> Self {
self.max_batch_bytes = max_batch_bytes;
self
}
pub fn with_max_buffered_records(mut self, max_buffered_records: usize) -> Self {
self.max_buffered_records = max_buffered_records;
self
}
pub fn with_include_device_id(mut self, include_device_id: bool) -> Self {
self.include_device_id = include_device_id;
self
}
pub fn api_key(&self) -> Option<&str> {
self.api_key.as_deref()
}
pub fn app_id(&self) -> Option<&str> {
self.app_id.as_deref()
}
pub fn app_version(&self) -> &str {
&self.app_version
}
pub fn debug(&self) -> bool {
self.debug
}
pub fn flush_interval(&self) -> Duration {
self.flush_interval
}
pub fn max_batch_bytes(&self) -> usize {
self.max_batch_bytes
}
pub fn max_buffered_records(&self) -> usize {
self.max_buffered_records
}
pub fn include_device_id(&self) -> bool {
self.include_device_id
}
pub fn validate(&self) -> Result<(), &'static str> {
fn header_safe(value: Option<&str>) -> bool {
value.is_some_and(|v| !v.is_empty() && v.bytes().all(|b| (0x20..=0x7e).contains(&b)))
}
if !header_safe(self.api_key.as_deref()) {
return Err("api_key");
}
if !header_safe(self.app_id.as_deref()) {
return Err("app_id");
}
if self.app_version.is_empty() {
return Err("app_version");
}
if self.max_batch_bytes == 0 || self.max_batch_bytes > MAX_BATCH_BYTES_LIMIT {
return Err("max_batch_bytes");
}
if self.max_buffered_records == 0 {
return Err("max_buffered_records");
}
if self.flush_interval < MIN_FLUSH_INTERVAL {
return Err("flush_interval_ms");
}
Ok(())
}
#[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());
assert!(cfg.api_key().is_none());
assert!(cfg.app_id().is_none());
assert_eq!(cfg.app_version(), "0.0.0");
assert!(!cfg.debug());
assert_eq!(cfg.flush_interval(), Duration::from_secs(30));
assert_eq!(cfg.max_batch_bytes(), 256 * 1024);
assert_eq!(cfg.max_buffered_records(), 4096);
assert!(
!cfg.include_device_id(),
"a stable id is a disclosure decision"
);
}
#[test]
fn validate_names_the_first_field_it_refuses() {
let valid = TelemetryConfig::default()
.with_api_key("mp_k")
.with_app_id("app_1 (prod)");
assert_eq!(valid.validate(), Ok(()));
assert_eq!(TelemetryConfig::default().validate(), Err("api_key"));
assert_eq!(valid.clone().with_api_key("").validate(), Err("api_key"));
assert_eq!(
valid.clone().with_api_key("k\u{e9}y").validate(),
Err("api_key")
);
assert_eq!(valid.clone().with_app_id("").validate(), Err("app_id"));
assert_eq!(valid.clone().with_app_id("app\n").validate(), Err("app_id"));
assert_eq!(
valid.clone().with_app_version("").validate(),
Err("app_version")
);
assert_eq!(
valid.clone().with_max_batch_bytes(0).validate(),
Err("max_batch_bytes")
);
assert_eq!(
valid
.clone()
.with_max_batch_bytes(MAX_BATCH_BYTES_LIMIT + 1)
.validate(),
Err("max_batch_bytes")
);
assert_eq!(
valid.clone().with_max_buffered_records(0).validate(),
Err("max_buffered_records")
);
assert_eq!(
valid
.clone()
.with_flush_interval(Duration::from_millis(999))
.validate(),
Err("flush_interval_ms")
);
}
#[test]
fn debug_redacts_the_api_key() {
let cfg = TelemetryConfig::default().with_api_key("mp_secret_key");
let rendered = format!("{cfg:?}");
assert!(!rendered.contains("mp_secret_key"), "{rendered}");
assert!(
rendered.contains("api_key: Some(\"<redacted>\")"),
"{rendered}"
);
}
#[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}");
}
}