cratefield_module_telemetry/lib.rs
1//! Aggregate usage telemetry for a Cratefield venture (issue #413): the
2//! collector, the closed payload grammar and the consent model, in one
3//! crate. Clients report **counted** events; every field is validated
4//! against a fixed vocabulary — an unknown field or an undeclared value is
5//! a rejection, never a truncation or a coercion — and the counts
6//! accumulate into buckets in the venture's own database, under the same
7//! retention and erasure rules as the rest of its tables.
8//!
9//! # Composition
10//!
11//! ```no_run
12//! use cratefield_module_telemetry::Telemetry;
13//!
14//! let module = Telemetry::new()
15//! .events(["run", "report", "upgrade"]) // closed event vocabulary
16//! .modules(["telemetry", "waitlist"]) // closed composed-module vocabulary
17//! .retention_days(180)
18//! .max_events_per_batch(64)
19//! .opt_out_command("fz telemetry off")
20//! .status_command("fz telemetry status");
21//! ```
22//!
23//! Both vocabularies default to **empty, which fails closed**: a venture
24//! that forgets to declare them runs a collector that rejects every batch
25//! naming an event or a module, not one that counts anything it is sent.
26//!
27//! # Consent
28//!
29//! The consent model lives in [`consent`]: a pure decision over the
30//! environment (the local opt-out, `DO_NOT_TRACK`, `CI`), a first-run
31//! notice generated from the payload schema so it cannot drift, a `status`
32//! rendering of the exact bytes that would be sent, and an install id the
33//! caller mints from random bytes and rotates every [`consent::ROTATION_DAYS`]
34//! days. The crate itself never generates randomness and never opens a
35//! connection, which is what keeps it wasm-safe and dependency-free.
36//!
37//! See `docs/TELEMETRY.md` for the published client contract this crate
38//! implements.
39
40#![doc = include_str!("../README.md")]
41#![forbid(unsafe_code)]
42
43mod handlers;
44mod store;
45
46pub mod consent;
47pub mod payload;
48
49use std::sync::OnceLock;
50
51// Re-exported because it appears in public signatures (`consent::notice`,
52// `consent::status` take it): a caller should be able to name what it holds.
53pub use handlers::Settings;
54
55use cratefield_core::{
56 AnyError, BoxFuture, Config, ConfigError, DataKind, Disposition, Migrations, Module,
57 ModuleConfig, ModuleContext, PersonalDataSet, Port, SqlMigration,
58};
59
60use crate::payload::Vocabulary;
61use time::OffsetDateTime;
62
63/// The longest retention a venture may configure, in days: 36,500 — about
64/// a century, several times any retention a venture has ever wanted. The
65/// ceiling exists because the purge computes `now - retention` as a real
66/// date, and a retention in the millions (a number of days fat-fingered
67/// from milliseconds or seconds) walks that date out of the representable
68/// calendar; catching it at build time is cheaper than a failed run on
69/// every cron fire (issue #413).
70pub const MAX_RETENTION_DAYS: u32 = 36_500;
71
72/// The module's migration: the two aggregate tables in the portable SQL
73/// subset (issue #413, ADR 0004 — applied verbatim on Postgres while the
74/// postgres list is empty).
75const MIGRATION_INIT: SqlMigration = SqlMigration::new(
76 "0001",
77 "init",
78 include_str!("../migrations/sqlite/0001_init.sql"),
79);
80
81/// Aggregate usage telemetry with a consent model built in (issue #413).
82///
83/// Compose it for the vocabularies a venture wants counted; everything
84/// else — routes, grammar, storage, retention — is fixed by the module.
85pub struct Telemetry {
86 settings: Settings,
87}
88
89impl Default for Telemetry {
90 fn default() -> Self {
91 Self::new()
92 }
93}
94
95impl Telemetry {
96 /// Empty vocabularies (fail closed: every batch naming an event or a
97 /// module is rejected until the venture declares them), the 64-event
98 /// batch ceiling, 180-day retention, and the `fz telemetry` commands.
99 #[must_use]
100 pub fn new() -> Self {
101 Self {
102 settings: Settings {
103 vocabulary: Vocabulary::default(),
104 retention_days: 180,
105 opt_out_command: "fz telemetry off".to_owned(),
106 status_command: "fz telemetry status".to_owned(),
107 },
108 }
109 }
110
111 /// Declares the whole event vocabulary. Exactly these names may be
112 /// counted; anything else is a rejection, never a new series (issue
113 /// #413). Replaces any previous list.
114 #[must_use]
115 pub fn events(mut self, events: impl IntoIterator<Item = impl Into<String>>) -> Self {
116 self.settings.vocabulary.events = events.into_iter().map(Into::into).collect();
117 self
118 }
119
120 /// Declares the whole composed-module vocabulary a client may report.
121 /// Replaces any previous list.
122 #[must_use]
123 pub fn modules(mut self, modules: impl IntoIterator<Item = impl Into<String>>) -> Self {
124 self.settings.vocabulary.modules = modules.into_iter().map(Into::into).collect();
125 self
126 }
127
128 /// How long buckets live before the scheduled purge deletes them
129 /// (default 180). Clamped to `1..=[MAX_RETENTION_DAYS]`: a builder
130 /// cannot return an error, and zero would delete every bucket the day
131 /// it is written while a value past the ceiling would walk the purge's
132 /// date arithmetic off the calendar — so an impossible value is
133 /// narrowed to the nearest workable one rather than honoured (issue
134 /// #413).
135 #[must_use]
136 pub fn retention_days(mut self, days: u32) -> Self {
137 self.settings.retention_days = days.clamp(1, MAX_RETENTION_DAYS);
138 self
139 }
140
141 /// The largest batch the collector parses (default 64). Clamped to
142 /// `1..=[payload::MAX_EVENTS_PER_BATCH]`: "at most this many" is part
143 /// of the closed grammar, so a venture may tighten it and never widen
144 /// it, and zero — which the clamp also removes — would leave a
145 /// collector that rejects every batch with a message about exceeding a
146 /// ceiling of nothing (issue #413).
147 #[must_use]
148 pub fn max_events_per_batch(mut self, max: u32) -> Self {
149 self.settings.vocabulary.max_events = usize::try_from(max)
150 .unwrap_or(crate::payload::MAX_EVENTS_PER_BATCH)
151 .clamp(1, crate::payload::MAX_EVENTS_PER_BATCH);
152 self
153 }
154
155 /// The command a client prints as the one-line way to switch reporting
156 /// off (default `fz telemetry off`).
157 #[must_use]
158 pub fn opt_out_command(mut self, command: impl Into<String>) -> Self {
159 self.settings.opt_out_command = command.into();
160 self
161 }
162
163 /// The command a client prints as the way to see exactly what would be
164 /// sent (default `fz telemetry status`).
165 #[must_use]
166 pub fn status_command(mut self, command: impl Into<String>) -> Self {
167 self.settings.status_command = command.into();
168 self
169 }
170}
171
172impl Module for Telemetry {
173 fn name(&self) -> &'static str {
174 "telemetry"
175 }
176
177 fn version(&self) -> &'static str {
178 env!("CARGO_PKG_VERSION")
179 }
180
181 /// The database is where the aggregates live; there is no mode of this
182 /// module without it.
183 fn requires(&self) -> &'static [Port] {
184 &[Port::Db]
185 }
186
187 /// The Clock port decides "now" — the day buckets and the retention
188 /// cutoff — and is what makes time deterministic under test. The
189 /// `RateLimiter` port is the abuse control the public ingest route
190 /// actually has.
191 fn optional(&self) -> &'static [Port] {
192 &[Port::Clock, Port::RateLimiter]
193 }
194
195 fn tables(&self) -> &'static [&'static str] {
196 &["telemetry_events", "telemetry_modules"]
197 }
198
199 /// Two aggregate tables, both keyed on the install id, both erased on
200 /// request (issue #413).
201 ///
202 /// **The tension, and the decided answer.** The payload carries no
203 /// identifier the venture can tie to a person, so the harness cannot
204 /// look a subject up from anything else it stores — and a reader might
205 /// conclude these tables hold nobody. They do not hold a person, but
206 /// they do hold a key: the install id is **pseudonymous, with no
207 /// stored link to a person**, and the person's own client prints it in
208 /// `telemetry status`. So an erasure request that supplies the id
209 /// deletes exactly their rows — the same person can act on their own
210 /// data without the venture ever learning who they are. That makes the
211 /// subject the install id and the disposition plain `Erase`;
212 /// declaring `none` here would be the lie that breaks the first
213 /// erasure request that names a real id.
214 ///
215 /// **The honest consequence, written down rather than buried.** The id
216 /// rotates every [`consent::ROTATION_DAYS`] days and nothing anywhere
217 /// stores the link between one id and the next — that is the point of
218 /// rotating it — so rows under an id that has already rotated away are
219 /// not reachable from a later one. Rotation bounds what an erasure can
220 /// reach, and that is the price of not keeping a link between
221 /// rotations; the retention purge bounds how long the unreachable tail
222 /// outlives the request.
223 fn personal_data(&self) -> &'static [PersonalDataSet] {
224 static SETS: OnceLock<Vec<PersonalDataSet>> = OnceLock::new();
225 SETS.get_or_init(|| {
226 // The description quotes the rotation interval for the person
227 // deciding whether to trust the table, and
228 // `PersonalDataSet::description` is `&'static str`, which no
229 // compile-time formatting can fill from
230 // [`consent::ROTATION_DAYS`]. The sentence is therefore built
231 // once, here, and the text leaked — a process-sized handful of
232 // bytes — so the interval is written down in the constant
233 // alone rather than spelled a second time as a literal that
234 // could drift from it.
235 let events_description: &'static str = Box::leak(
236 format!(
237 "Counts of what your client did — which of this venture's named \
238 events ran, how they ended, in which duration bucket and on which \
239 build — totalled per day under a random install id your client \
240 mints and replaces every {} days. There is no field here that \
241 could hold anything you wrote, named or typed.",
242 consent::ROTATION_DAYS
243 )
244 .into_boxed_str(),
245 );
246 vec![
247 PersonalDataSet {
248 table: "telemetry_events",
249 subject: "install_id",
250 kind: DataKind::Usage,
251 disposition: Disposition::Erase,
252 description: events_description,
253 redacted: &[],
254 subject_via: None,
255 },
256 PersonalDataSet {
257 table: "telemetry_modules",
258 subject: "install_id",
259 kind: DataKind::Usage,
260 disposition: Disposition::Erase,
261 description: "Which of this venture's modules your client reported \
262 composing, per day, under the same random install id.",
263 redacted: &[],
264 subject_via: None,
265 },
266 ]
267 })
268 .as_slice()
269 }
270
271 /// The event a composed venture can subscribe to: an accepted batch.
272 fn emits(&self) -> &'static [&'static str] {
273 &[handlers::EVENT_RECORDED]
274 }
275
276 /// The ingest route is public by design and still not an open write
277 /// path: it accepts one thing only — a batch whose every value already
278 /// passed a closed grammar, capped by the batch ceiling and the
279 /// `RateLimiter` port — and it is how a CLI reports, with no browser and
280 /// no session (issue #413, after module-linkedin's reasoning). It stays
281 /// a `false` here because `public_writes` feeds the captcha gate in
282 /// `route_policy.rs`, and a captcha on a machine-posted batch is not
283 /// satisfiable: the honest controls are the ones the route actually
284 /// has, and they are all mechanical.
285 fn public_writes(&self) -> bool {
286 false
287 }
288
289 fn migrations(&self) -> Migrations {
290 const MIGRATIONS: [SqlMigration; 1] = [MIGRATION_INIT];
291 const _: () = cratefield_core::assert_migration_set(&MIGRATIONS);
292 Migrations::sqlite(&MIGRATIONS)
293 }
294
295 fn validate_config(&self, cfg: &dyn Config) -> Result<(), ConfigError> {
296 let module = ModuleConfig::new("telemetry", cfg);
297 let mut errors = ConfigError::default();
298
299 // Retention: a positive whole number of days, and no more than a
300 // century of them. The negated form is the point: `is_ok_and`
301 // alone would let a value that does not parse at all — "nonsense",
302 // "-5", "12.5" — slip through and silently take the runtime
303 // default in the purge, which is a decision the config never
304 // made. The upper bound turns a fat-fingered number of smaller
305 // units into an error here instead of a date the purge cannot
306 // compute (see [`MAX_RETENTION_DAYS`]).
307 if let Some(raw) = module
308 .get_opt("RETENTION_DAYS")
309 .map(|raw| raw.parse::<u32>())
310 && !raw.is_ok_and(|days| days > 0 && days <= MAX_RETENTION_DAYS)
311 {
312 errors.push(format!(
313 "telemetry: {} must be a whole number of days from 1 to {}",
314 module.key("RETENTION_DAYS"),
315 MAX_RETENTION_DAYS
316 ));
317 }
318
319 // The batch ceiling: a positive integer, never above the schema
320 // ceiling of 64. Wider than the grammar would let a venture widen
321 // what a single request can carry, which is exactly the bound the
322 // ceiling exists to be (issue #413).
323 let ceiling = u32::try_from(crate::payload::MAX_EVENTS_PER_BATCH).unwrap_or_default();
324 if let Some(raw) = module
325 .get_opt("MAX_EVENTS_PER_BATCH")
326 .map(|raw| raw.parse::<u32>())
327 && !raw.is_ok_and(|max| max > 0 && max <= ceiling)
328 {
329 errors.push(format!(
330 "telemetry: {} must be a positive integer no greater than {}",
331 module.key("MAX_EVENTS_PER_BATCH"),
332 crate::payload::MAX_EVENTS_PER_BATCH
333 ));
334 }
335
336 // The declared vocabularies are venture-chosen names, the one part
337 // of the store that is not a closed value: an empty or
338 // control-character name is a config bug, and a name carrying `|`
339 // would ride the bucket key's separator (see the store). Checking
340 // here fails the harness at build time, where the fix is made,
341 // rather than at the first write.
342 for (which, names) in [
343 ("event", &self.settings.vocabulary.events),
344 ("module", &self.settings.vocabulary.modules),
345 ] {
346 for name in names {
347 let admissible = !name.trim().is_empty()
348 && !name.contains('|')
349 && !name.chars().any(char::is_control);
350 if !admissible {
351 errors.push(format!(
352 "telemetry: declared {which} names must be non-empty, with no \
353 `|` and no control characters; {name:?} is not"
354 ));
355 }
356 }
357 }
358
359 errors.into_result()
360 }
361
362 fn router(&self, ctx: ModuleContext) -> axum::Router {
363 handlers::router(ctx, self.settings.clone())
364 }
365
366 /// The retention purge: buckets in either table whose day is older
367 /// than the window are deleted, mirroring the waitlist's scheduled
368 /// purge. Idempotent, so running it more often than the cron fires is
369 /// harmless.
370 fn scheduled<'a>(
371 &'a self,
372 ctx: &'a ModuleContext,
373 cron: &'a str,
374 ) -> BoxFuture<'a, Result<(), AnyError>> {
375 Box::pin(async move {
376 let Some(db) = ctx.ports.db.clone() else {
377 return Ok(());
378 };
379 let cfg = ModuleConfig::new("telemetry", &*ctx.config);
380 let days = i64::from(cfg.get_u32("RETENTION_DAYS", self.settings.retention_days));
381 // "Now" through the Clock port when present, SystemClock
382 // otherwise (issue #413).
383 let now = handlers::now_of(ctx);
384 let cutoff = handlers::day_of(retention_cutoff(now, days)?);
385 let deleted = store::purge_older_than(&*db, &cutoff)
386 .await
387 .map_err(|err| Box::new(err) as AnyError)?;
388 if deleted > 0 {
389 tracing::info!(deleted, cron, "purged telemetry buckets past retention");
390 }
391 Ok(())
392 })
393 }
394}
395
396/// The oldest day the retention window still keeps: `now` shifted back
397/// `days` days. The shift is checked because `days` is an operator's
398/// number, reached here without a `validate_config` in between: past the
399/// calendar's edge a plain subtraction panics, and a fat-fingered value
400/// must cost a failed run with the number named, not a panic (issue
401/// #413).
402fn retention_cutoff(now: OffsetDateTime, days: i64) -> Result<OffsetDateTime, AnyError> {
403 now.checked_sub(time::Duration::days(days)).ok_or_else(|| {
404 AnyError::from(format!(
405 "telemetry: the retention window of {days} days reaches outside the \
406 calendar"
407 ))
408 })
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use std::sync::Arc;
415
416 #[test]
417 fn defaults_fail_closed() {
418 let module = Telemetry::new();
419 assert!(
420 module.settings.vocabulary.events.is_empty(),
421 "no declared events"
422 );
423 assert!(
424 module.settings.vocabulary.modules.is_empty(),
425 "no declared modules"
426 );
427 assert_eq!(
428 module.settings.vocabulary.max_events,
429 crate::payload::MAX_EVENTS_PER_BATCH
430 );
431 assert_eq!(module.settings.retention_days, 180);
432 assert_eq!(module.settings.opt_out_command, "fz telemetry off");
433 assert_eq!(module.settings.status_command, "fz telemetry status");
434 }
435
436 #[test]
437 fn the_builder_sets_the_settings_it_names() {
438 let module = Telemetry::new()
439 .events(["run", "report"])
440 .modules(["telemetry"])
441 .retention_days(90)
442 .max_events_per_batch(8)
443 .opt_out_command("acme off")
444 .status_command("acme status");
445 assert_eq!(module.settings.vocabulary.events, ["run", "report"]);
446 assert_eq!(module.settings.vocabulary.modules, ["telemetry"]);
447 assert_eq!(module.settings.retention_days, 90);
448 assert_eq!(module.settings.vocabulary.max_events, 8);
449 assert_eq!(module.settings.opt_out_command, "acme off");
450 assert_eq!(module.settings.status_command, "acme status");
451 }
452
453 #[test]
454 fn module_metadata() {
455 let module = Telemetry::new();
456 assert_eq!(module.name(), "telemetry");
457 assert_eq!(module.version(), env!("CARGO_PKG_VERSION"));
458 assert_eq!(module.requires(), [Port::Db]);
459 assert_eq!(module.optional(), [Port::Clock, Port::RateLimiter]);
460 assert_eq!(module.tables(), ["telemetry_events", "telemetry_modules"]);
461 assert_eq!(module.emits(), ["telemetry.recorded"]);
462 assert!(!module.public_writes());
463 assert!(
464 module.migrations().postgres.is_empty(),
465 "sqlite migrations only"
466 );
467 assert_eq!(module.migrations().sqlite.len(), 1);
468 }
469
470 #[test]
471 fn a_widened_batch_ceiling_is_clamped_to_the_schema_ceiling() {
472 let module = Telemetry::new().max_events_per_batch(1_000);
473 assert_eq!(
474 module.settings.vocabulary.max_events,
475 crate::payload::MAX_EVENTS_PER_BATCH,
476 "the ceiling is part of the grammar and cannot be widened"
477 );
478 }
479
480 #[test]
481 fn valid_config_passes_and_invalid_config_names_its_keys() {
482 let module = Telemetry::new();
483 let ok: Arc<dyn Config> = Arc::new(cratefield_core::MapConfig::from_pairs([
484 ("TELEMETRY_RETENTION_DAYS".to_owned(), "30".to_owned()),
485 ("TELEMETRY_MAX_EVENTS_PER_BATCH".to_owned(), "16".to_owned()),
486 ]));
487 assert!(module.validate_config(&*ok).is_ok());
488
489 let bad: Arc<dyn Config> = Arc::new(cratefield_core::MapConfig::from_pairs([
490 ("TELEMETRY_RETENTION_DAYS".to_owned(), "0".to_owned()),
491 ("TELEMETRY_MAX_EVENTS_PER_BATCH".to_owned(), "65".to_owned()),
492 ]));
493 let errors = module
494 .validate_config(&*bad)
495 .expect_err("both keys are invalid");
496 let text = format!("{errors}");
497 assert!(text.contains("TELEMETRY_RETENTION_DAYS"), "{text}");
498 assert!(text.contains("TELEMETRY_MAX_EVENTS_PER_BATCH"), "{text}");
499 }
500
501 /// A value that does not parse at all must be rejected, not silently
502 /// exchanged for the runtime default: the config said something, and
503 /// "nonsense" is not the 180 days nobody chose.
504 #[test]
505 fn a_retention_that_is_not_a_whole_number_of_days_is_rejected() {
506 let module = Telemetry::new();
507 for raw in ["nonsense", "-5", "12.5", "36501"] {
508 let bad: Arc<dyn Config> = Arc::new(cratefield_core::MapConfig::from_pairs([(
509 "TELEMETRY_RETENTION_DAYS".to_owned(),
510 raw.to_owned(),
511 )]));
512 let errors = module
513 .validate_config(&*bad)
514 .expect_err("the value is not a usable number of days");
515 let text = format!("{errors}");
516 assert!(
517 text.contains("TELEMETRY_RETENTION_DAYS"),
518 "{raw}: the error must name the variable: {text}"
519 );
520 }
521 }
522
523 /// The declared vocabularies are the one venture-chosen part of the
524 /// store, so they must still be names: a `|` would ride the bucket
525 /// key's separator, an empty name is not a name, and a control
526 /// character is a payload with a newline in it.
527 #[test]
528 fn declared_names_must_be_names() {
529 let no_config: Arc<dyn Config> = Arc::new(cratefield_core::MapConfig::from_pairs(Vec::<(
530 String,
531 String,
532 )>::new(
533 )));
534
535 let separator = Telemetry::new().events(["run|ok"]);
536 let errors = separator
537 .validate_config(&*no_config)
538 .expect_err("a `|` would ride the bucket key's separator");
539 assert!(format!("{errors}").contains("run|ok"), "{errors}");
540
541 let blank = Telemetry::new().events([""]);
542 let errors = blank
543 .validate_config(&*no_config)
544 .expect_err("an empty name is not a name");
545 assert!(format!("{errors}").contains("non-empty"), "{errors}");
546
547 let control = Telemetry::new().modules(["telemetry\n"]);
548 let errors = control
549 .validate_config(&*no_config)
550 .expect_err("a control character is not a name");
551 assert!(
552 format!("{errors}").contains("control characters"),
553 "{errors}"
554 );
555 }
556
557 /// The builder cannot return an error, so an impossible value is
558 /// narrowed to the nearest workable one — and the doc comment says so.
559 /// Zero retention deletes every bucket the day it is written; a zero
560 /// batch ceiling is a collector that rejects everything for exceeding
561 /// a ceiling of nothing; past `MAX_RETENTION_DAYS` the purge's date
562 /// arithmetic cannot run.
563 #[test]
564 fn builder_zeroes_and_oversized_values_are_narrowed_not_honoured() {
565 let module = Telemetry::new().retention_days(0).max_events_per_batch(0);
566 assert_eq!(module.settings.retention_days, 1);
567 assert_eq!(module.settings.vocabulary.max_events, 1);
568
569 let module = Telemetry::new().retention_days(u32::MAX);
570 assert_eq!(module.settings.retention_days, MAX_RETENTION_DAYS);
571 }
572
573 /// The purge's shift is checked: an operator number past the
574 /// calendar's edge — the size of a fat-fingered milliseconds value —
575 /// comes back as an error naming the problem, not a panic on every
576 /// cron fire.
577 #[test]
578 fn an_impossible_retention_is_an_error_not_a_panic() {
579 let now = OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("timestamp");
580 let sane = retention_cutoff(now, 30).expect("a month is inside the calendar");
581 assert_eq!(
582 handlers::day_of(sane),
583 handlers::day_of(now - time::Duration::days(30))
584 );
585 assert!(
586 retention_cutoff(now, 4_400_000).is_err(),
587 "past the calendar's edge the cutoff must error, not panic"
588 );
589 }
590}