#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
mod handlers;
mod store;
pub mod consent;
pub mod payload;
use std::sync::OnceLock;
pub use handlers::Settings;
use cratefield_core::{
AnyError, BoxFuture, Config, ConfigError, DataKind, Disposition, Migrations, Module,
ModuleConfig, ModuleContext, PersonalDataSet, Port, SqlMigration,
};
use crate::payload::Vocabulary;
use time::OffsetDateTime;
pub const MAX_RETENTION_DAYS: u32 = 36_500;
const MIGRATION_INIT: SqlMigration = SqlMigration::new(
"0001",
"init",
include_str!("../migrations/sqlite/0001_init.sql"),
);
pub struct Telemetry {
settings: Settings,
}
impl Default for Telemetry {
fn default() -> Self {
Self::new()
}
}
impl Telemetry {
#[must_use]
pub fn new() -> Self {
Self {
settings: Settings {
vocabulary: Vocabulary::default(),
retention_days: 180,
opt_out_command: "fz telemetry off".to_owned(),
status_command: "fz telemetry status".to_owned(),
},
}
}
#[must_use]
pub fn events(mut self, events: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.settings.vocabulary.events = events.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn modules(mut self, modules: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.settings.vocabulary.modules = modules.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn retention_days(mut self, days: u32) -> Self {
self.settings.retention_days = days.clamp(1, MAX_RETENTION_DAYS);
self
}
#[must_use]
pub fn max_events_per_batch(mut self, max: u32) -> Self {
self.settings.vocabulary.max_events = usize::try_from(max)
.unwrap_or(crate::payload::MAX_EVENTS_PER_BATCH)
.clamp(1, crate::payload::MAX_EVENTS_PER_BATCH);
self
}
#[must_use]
pub fn opt_out_command(mut self, command: impl Into<String>) -> Self {
self.settings.opt_out_command = command.into();
self
}
#[must_use]
pub fn status_command(mut self, command: impl Into<String>) -> Self {
self.settings.status_command = command.into();
self
}
}
impl Module for Telemetry {
fn name(&self) -> &'static str {
"telemetry"
}
fn version(&self) -> &'static str {
env!("CARGO_PKG_VERSION")
}
fn requires(&self) -> &'static [Port] {
&[Port::Db]
}
fn optional(&self) -> &'static [Port] {
&[Port::Clock, Port::RateLimiter]
}
fn tables(&self) -> &'static [&'static str] {
&["telemetry_events", "telemetry_modules"]
}
fn personal_data(&self) -> &'static [PersonalDataSet] {
static SETS: OnceLock<Vec<PersonalDataSet>> = OnceLock::new();
SETS.get_or_init(|| {
let events_description: &'static str = Box::leak(
format!(
"Counts of what your client did — which of this venture's named \
events ran, how they ended, in which duration bucket and on which \
build — totalled per day under a random install id your client \
mints and replaces every {} days. There is no field here that \
could hold anything you wrote, named or typed.",
consent::ROTATION_DAYS
)
.into_boxed_str(),
);
vec![
PersonalDataSet {
table: "telemetry_events",
subject: "install_id",
kind: DataKind::Usage,
disposition: Disposition::Erase,
description: events_description,
redacted: &[],
subject_via: None,
},
PersonalDataSet {
table: "telemetry_modules",
subject: "install_id",
kind: DataKind::Usage,
disposition: Disposition::Erase,
description: "Which of this venture's modules your client reported \
composing, per day, under the same random install id.",
redacted: &[],
subject_via: None,
},
]
})
.as_slice()
}
fn emits(&self) -> &'static [&'static str] {
&[handlers::EVENT_RECORDED]
}
fn public_writes(&self) -> bool {
false
}
fn migrations(&self) -> Migrations {
const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
Migrations::sqlite(&MIGRATIONS)
}
fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
let module = ModuleConfig::new("telemetry", cfg);
let mut errors = ConfigError::default();
if let Some(raw) = module
.get_opt("RETENTION_DAYS")
.map(|raw| raw.parse::<u32>())
&& !raw.is_ok_and(|days| days > 0 && days <= MAX_RETENTION_DAYS)
{
errors.push(format!(
"telemetry: {} must be a whole number of days from 1 to {}",
module.key("RETENTION_DAYS"),
MAX_RETENTION_DAYS
));
}
let ceiling = u32::try_from(crate::payload::MAX_EVENTS_PER_BATCH).unwrap_or_default();
if let Some(raw) = module
.get_opt("MAX_EVENTS_PER_BATCH")
.map(|raw| raw.parse::<u32>())
&& !raw.is_ok_and(|max| max > 0 && max <= ceiling)
{
errors.push(format!(
"telemetry: {} must be a positive integer no greater than {}",
module.key("MAX_EVENTS_PER_BATCH"),
crate::payload::MAX_EVENTS_PER_BATCH
));
}
for (which, names) in [
("event", &self.settings.vocabulary.events),
("module", &self.settings.vocabulary.modules),
] {
for name in names {
let admissible = !name.trim().is_empty()
&& !name.contains('|')
&& !name.chars().any(char::is_control);
if !admissible {
errors.push(format!(
"telemetry: declared {which} names must be non-empty, with no \
`|` and no control characters; {name:?} is not"
));
}
}
}
errors.into_result()
}
fn router(&self, ctx: ModuleContext) -> axum::Router {
handlers::router(ctx, self.settings.clone())
}
fn scheduled<'a>(
&'a self,
ctx: &'a ModuleContext,
cron: &'a str,
) -> BoxFuture<'a, Result<(), AnyError>> {
Box::pin(async move {
let Some(db) = ctx.ports.db.clone() else {
return Ok(());
};
let cfg = ModuleConfig::new("telemetry", &*ctx.config);
let days = i64::from(cfg.get_u32("RETENTION_DAYS", self.settings.retention_days));
let now = handlers::now_of(ctx);
let cutoff = handlers::day_of(retention_cutoff(now, days)?);
let deleted = store::purge_older_than(&*db, &cutoff)
.await
.map_err(|err| Box::new(err) as AnyError)?;
if deleted > 0 {
tracing::info!(deleted, cron, "purged telemetry buckets past retention");
}
Ok(())
})
}
}
fn retention_cutoff(now: OffsetDateTime, days: i64) -> Result<OffsetDateTime, AnyError> {
now.checked_sub(time::Duration::days(days)).ok_or_else(|| {
AnyError::from(format!(
"telemetry: the retention window of {days} days reaches outside the \
calendar"
))
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn defaults_fail_closed() {
let module = Telemetry::new();
assert!(
module.settings.vocabulary.events.is_empty(),
"no declared events"
);
assert!(
module.settings.vocabulary.modules.is_empty(),
"no declared modules"
);
assert_eq!(
module.settings.vocabulary.max_events,
crate::payload::MAX_EVENTS_PER_BATCH
);
assert_eq!(module.settings.retention_days, 180);
assert_eq!(module.settings.opt_out_command, "fz telemetry off");
assert_eq!(module.settings.status_command, "fz telemetry status");
}
#[test]
fn the_builder_sets_the_settings_it_names() {
let module = Telemetry::new()
.events(["run", "report"])
.modules(["telemetry"])
.retention_days(90)
.max_events_per_batch(8)
.opt_out_command("acme off")
.status_command("acme status");
assert_eq!(module.settings.vocabulary.events, ["run", "report"]);
assert_eq!(module.settings.vocabulary.modules, ["telemetry"]);
assert_eq!(module.settings.retention_days, 90);
assert_eq!(module.settings.vocabulary.max_events, 8);
assert_eq!(module.settings.opt_out_command, "acme off");
assert_eq!(module.settings.status_command, "acme status");
}
#[test]
fn module_metadata() {
let module = Telemetry::new();
assert_eq!(module.name(), "telemetry");
assert_eq!(module.version(), env!("CARGO_PKG_VERSION"));
assert_eq!(module.requires(), [Port::Db]);
assert_eq!(module.optional(), [Port::Clock, Port::RateLimiter]);
assert_eq!(module.tables(), ["telemetry_events", "telemetry_modules"]);
assert_eq!(module.emits(), ["telemetry.recorded"]);
assert!(!module.public_writes());
assert!(
module.migrations().postgres.is_empty(),
"sqlite migrations only"
);
assert_eq!(module.migrations().sqlite.len(), 1);
}
#[test]
fn a_widened_batch_ceiling_is_clamped_to_the_schema_ceiling() {
let module = Telemetry::new().max_events_per_batch(1_000);
assert_eq!(
module.settings.vocabulary.max_events,
crate::payload::MAX_EVENTS_PER_BATCH,
"the ceiling is part of the grammar and cannot be widened"
);
}
#[test]
fn valid_config_passes_and_invalid_config_names_its_keys() {
let module = Telemetry::new();
let ok: Arc<dyn Config> = Arc::new(cratefield_core::MapConfig::from_pairs([
("TELEMETRY_RETENTION_DAYS".to_owned(), "30".to_owned()),
("TELEMETRY_MAX_EVENTS_PER_BATCH".to_owned(), "16".to_owned()),
]));
assert!(module.validate_config(&*ok).is_ok());
let bad: Arc<dyn Config> = Arc::new(cratefield_core::MapConfig::from_pairs([
("TELEMETRY_RETENTION_DAYS".to_owned(), "0".to_owned()),
("TELEMETRY_MAX_EVENTS_PER_BATCH".to_owned(), "65".to_owned()),
]));
let errors = module
.validate_config(&*bad)
.expect_err("both keys are invalid");
let text = format!("{errors}");
assert!(text.contains("TELEMETRY_RETENTION_DAYS"), "{text}");
assert!(text.contains("TELEMETRY_MAX_EVENTS_PER_BATCH"), "{text}");
}
#[test]
fn a_retention_that_is_not_a_whole_number_of_days_is_rejected() {
let module = Telemetry::new();
for raw in ["nonsense", "-5", "12.5", "36501"] {
let bad: Arc<dyn Config> = Arc::new(cratefield_core::MapConfig::from_pairs([(
"TELEMETRY_RETENTION_DAYS".to_owned(),
raw.to_owned(),
)]));
let errors = module
.validate_config(&*bad)
.expect_err("the value is not a usable number of days");
let text = format!("{errors}");
assert!(
text.contains("TELEMETRY_RETENTION_DAYS"),
"{raw}: the error must name the variable: {text}"
);
}
}
#[test]
fn declared_names_must_be_names() {
let no_config: Arc<dyn Config> = Arc::new(cratefield_core::MapConfig::from_pairs(Vec::<(
String,
String,
)>::new(
)));
let separator = Telemetry::new().events(["run|ok"]);
let errors = separator
.validate_config(&*no_config)
.expect_err("a `|` would ride the bucket key's separator");
assert!(format!("{errors}").contains("run|ok"), "{errors}");
let blank = Telemetry::new().events([""]);
let errors = blank
.validate_config(&*no_config)
.expect_err("an empty name is not a name");
assert!(format!("{errors}").contains("non-empty"), "{errors}");
let control = Telemetry::new().modules(["telemetry\n"]);
let errors = control
.validate_config(&*no_config)
.expect_err("a control character is not a name");
assert!(
format!("{errors}").contains("control characters"),
"{errors}"
);
}
#[test]
fn builder_zeroes_and_oversized_values_are_narrowed_not_honoured() {
let module = Telemetry::new().retention_days(0).max_events_per_batch(0);
assert_eq!(module.settings.retention_days, 1);
assert_eq!(module.settings.vocabulary.max_events, 1);
let module = Telemetry::new().retention_days(u32::MAX);
assert_eq!(module.settings.retention_days, MAX_RETENTION_DAYS);
}
#[test]
fn an_impossible_retention_is_an_error_not_a_panic() {
let now = OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("timestamp");
let sane = retention_cutoff(now, 30).expect("a month is inside the calendar");
assert_eq!(
handlers::day_of(sane),
handlers::day_of(now - time::Duration::days(30))
);
assert!(
retention_cutoff(now, 4_400_000).is_err(),
"past the calendar's edge the cutoff must error, not panic"
);
}
}