Skip to main content

ev/
events.rs

1//! A local, append-only events log (results/events.jsonl) — the decision-data埋点 for
2//! metrics. Gitignored, 0-network, best-effort (a write failure never fails the command).
3use crate::store::Store;
4use crate::tick::{source_ref_key, Tick};
5use serde_json::{json, Value};
6use std::io::Write;
7use time::{format_description::well_known::Rfc3339, OffsetDateTime};
8
9/// Append one event line: `{ts, op, tick_id?, source_ref?, age?, verdict?, masked_stale?}`. When a
10/// deciding tick is given it contributes the join key (`source_ref`) and a coarse decision-AGE bucket
11/// — together the prior-decision discriminator the metrics framework needs: a check firing on an OLD
12/// decision is a prior-decision resurface; on a just-written one it is not. `masked_stale` carries a
13/// stale sub-kind that the (worse) per-tick verdict hides, so a staleness-mask never silently drops.
14pub fn append(
15    store: &Store,
16    op: &str,
17    tick: Option<&Tick>,
18    verdict: Option<&str>,
19    masked_stale: Option<&str>,
20) {
21    let now = OffsetDateTime::now_utc();
22    let ts = now.format(&Rfc3339).unwrap_or_default();
23    let mut e = json!({ "ts": ts, "op": op });
24    if let Some(o) = e.as_object_mut() {
25        if let Some(t) = tick {
26            o.insert("tick_id".into(), Value::String(t.id.clone()));
27            if let Some(sr) = &t.source_ref {
28                o.insert("source_ref".into(), Value::String(source_ref_key(sr)));
29            }
30            if let Some(age) = age_bucket(&t.held_since, now.unix_timestamp()) {
31                o.insert("age".into(), Value::String(age.into()));
32            }
33        }
34        if let Some(v) = verdict {
35            o.insert("verdict".into(), Value::String(v.into()));
36        }
37        if let Some(m) = masked_stale {
38            o.insert("masked_stale".into(), Value::String(m.into()));
39        }
40    }
41    let dir = store.root.join("results");
42    if std::fs::create_dir_all(&dir).is_err() {
43        return;
44    }
45    // Best-effort: a write failure never fails the command (the log is a droppable, gitignored cache).
46    if let Ok(mut f) = std::fs::OpenOptions::new()
47        .create(true)
48        .append(true)
49        .open(dir.join("events.jsonl"))
50    {
51        let _ = writeln!(f, "{}", serde_json::to_string(&e).unwrap_or_default());
52    }
53}
54
55/// A coarse decision-age bucket from a tick's `held_since` (RFC3339) to `now_unix`: `fresh` (<1d) /
56/// `days` (<7d) / `weeks` (<30d) / `months` (<365d) / `year+` (>=365d). Coarse on purpose — the metric
57/// thresholds it into prior-decision vs just-written; it leaks no precision the tick does not carry.
58fn age_bucket(held_since: &str, now_unix: i64) -> Option<&'static str> {
59    let then = OffsetDateTime::parse(held_since, &Rfc3339).ok()?;
60    let days = (now_unix - then.unix_timestamp()) / 86_400;
61    Some(if days < 1 {
62        "fresh"
63    } else if days < 7 {
64        "days"
65    } else if days < 30 {
66        "weeks"
67    } else if days < 365 {
68        "months"
69    } else {
70        "year+"
71    })
72}
73
74#[cfg(test)]
75mod tests {
76    use super::age_bucket;
77    use time::{format_description::well_known::Rfc3339, OffsetDateTime};
78
79    const NOW: i64 = 1_750_000_000; // a fixed clock so the boundary arithmetic is deterministic
80
81    fn held(secs_ago: i64) -> String {
82        OffsetDateTime::from_unix_timestamp(NOW - secs_ago)
83            .unwrap()
84            .format(&Rfc3339)
85            .unwrap()
86    }
87
88    #[test]
89    fn age_bucket_should_label_each_threshold() {
90        let h = 3_600;
91        let d = 86_400;
92        // given/then: each side of every day-bucket boundary maps to the right coarse label
93        assert_eq!(age_bucket(&held(0), NOW), Some("fresh"));
94        assert_eq!(age_bucket(&held(23 * h), NOW), Some("fresh"));
95        assert_eq!(age_bucket(&held(25 * h), NOW), Some("days"));
96        assert_eq!(age_bucket(&held(6 * d), NOW), Some("days"));
97        assert_eq!(age_bucket(&held(8 * d), NOW), Some("weeks"));
98        assert_eq!(age_bucket(&held(29 * d), NOW), Some("weeks"));
99        assert_eq!(age_bucket(&held(31 * d), NOW), Some("months"));
100        assert_eq!(age_bucket(&held(364 * d), NOW), Some("months"));
101        assert_eq!(age_bucket(&held(366 * d), NOW), Some("year+"));
102    }
103
104    #[test]
105    fn age_bucket_should_be_none_when_held_since_is_unparseable() {
106        // given/then: a garbage or empty timestamp yields no bucket (a data fault, never a wrong label)
107        assert_eq!(age_bucket("not a timestamp", NOW), None);
108        assert_eq!(age_bucket("", NOW), None);
109    }
110}