Skip to main content

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}