Skip to main content

facett_trace/
lib.rs

1//! **facett-trace** — a structured event trace: the **machine-readable** sibling
2//! of a free-form action log.
3//!
4//! An action log is a human free-form trail (`tab=Bench`, `reload … → 3
5//! release(s)`). This is its typed counterpart: every instrumented operation
6//! emits its INPUT parameters and its OUTPUT *data* as real JSON payloads, so an
7//! external agent (or a headless test matrix) can read back **exactly the data
8//! the UI rendered** — the bubble table, the bench rows, the server response —
9//! not a summary and not a screenshot.
10//!
11//! Adopted verbatim from nornir's `src/viz/trace.rs`; it was already zero-coupling
12//! (generic over `serde::Serialize`). The only change is the output-path env var:
13//! `$FACETT_TRACE` (default `/tmp/facett_trace.jsonl`), truncated on launch.
14//!
15//! One JSON object per line (JSONL):
16//!
17//! ```json
18//! {"seq":7,"ts_ms":1733961825678,"stamp":"01:23:45.678",
19//!  "span":"knowledge.scan","phase":"in","data":{"workspace_root":"…","repos":["njord"]}}
20//! {"seq":8,...,"span":"knowledge.scan.repo","phase":"end","data":{"repo":"njord","symbols":642,…}}
21//! {"seq":9,...,"span":"knowledge.scan","phase":"out","data":{"repos_ok":1,"repos_total":1,"ms":83}}
22//! {"seq":10,...,"span":"knowledge.render","phase":"end","data":{"bubbles":[{"repo":"njord",…}]}}
23//! ```
24//!
25//! ## The contract
26//! - **span** — the operation name (`knowledge.scan`, `ui.tab`, `server.reload`).
27//! - **phase** — `in` (params accepted), `out` (result produced), `end` (a unit
28//!   of work finished), or `event` (a point-in-time fact, e.g. a tab switch).
29//!   Pair an `in` with its later `out`/`end` of the same `span` by adjacency —
30//!   the UI is single-threaded, so within one operation they don't interleave.
31//! - **data** — the real, structured payload. Emit typed structs (serialized via
32//!   serde), never a pre-formatted string, so the consumer parses fields.
33//!
34//! Cheap by design: a `Mutex<File>` `writeln!` + a global atomic seq. Call sites
35//! only emit on edge-triggered events (a scan, a click, an RPC), never per-frame.
36
37use std::io::Write;
38use std::sync::{Mutex, OnceLock};
39
40use serde::Serialize;
41
42/// Phase of an instrumented operation. Lets a consumer correlate the inputs a
43/// call accepted with the data it produced.
44#[derive(Clone, Copy, Debug, Serialize)]
45#[serde(rename_all = "lowercase")]
46pub enum Phase {
47    /// Inputs accepted — the parameters that drive the operation.
48    In,
49    /// Output produced — the result data the operation returns.
50    Out,
51    /// A unit of work finished (use when there's no separate in/out split).
52    End,
53    /// A point-in-time fact with no duration (a tab switch, a selection).
54    Event,
55}
56
57struct Sink {
58    inner: Mutex<Inner>,
59    path: String,
60}
61
62struct Inner {
63    seq: u64,
64    file: Option<std::fs::File>,
65}
66
67static SINK: OnceLock<Sink> = OnceLock::new();
68
69fn sink() -> &'static Sink {
70    SINK.get_or_init(|| {
71        let path = std::env::var("FACETT_TRACE")
72            .unwrap_or_else(|_| "/tmp/facett_trace.jsonl".to_string());
73        // Truncate on launch so each session's trace starts clean (matches the
74        // single-snapshot semantics of the rest of the facett harness sinks).
75        let file = std::fs::File::create(&path).ok();
76        let s = Sink { inner: Mutex::new(Inner { seq: 0, file }), path };
77        // A session header so the consumer knows the trace (re)started and where.
78        emit_into(&s, "trace.session", Phase::Event, &serde_json::json!({ "file": s.path }));
79        s
80    })
81}
82
83/// Emit one structured event: `span` is the operation, `phase` its stage, and
84/// `data` the real payload (any `Serialize` — a typed struct, ideally). Writes a
85/// single JSONL line to `$FACETT_TRACE`; never panics (best-effort I/O).
86pub fn emit<T: Serialize>(span: &str, phase: Phase, data: &T) {
87    emit_into(sink(), span, phase, data);
88}
89
90/// Convenience: the inputs a call accepted.
91pub fn emit_in<T: Serialize>(span: &str, data: &T) {
92    emit(span, Phase::In, data);
93}
94
95/// Convenience: the data a call produced.
96pub fn emit_out<T: Serialize>(span: &str, data: &T) {
97    emit(span, Phase::Out, data);
98}
99
100/// Convenience: a unit of work finished (no separate in/out).
101pub fn emit_end<T: Serialize>(span: &str, data: &T) {
102    emit(span, Phase::End, data);
103}
104
105/// Convenience: a point-in-time fact (tab switch, selection).
106pub fn emit_event<T: Serialize>(span: &str, data: &T) {
107    emit(span, Phase::Event, data);
108}
109
110fn emit_into<T: Serialize>(s: &Sink, span: &str, phase: Phase, data: &T) {
111    let now = chrono::Local::now();
112    let stamp = now.format("%H:%M:%S%.3f").to_string();
113    let ts_ms = now.timestamp_millis();
114    let Ok(mut g) = s.inner.lock() else { return };
115    g.seq += 1;
116    let line = serde_json::json!({
117        "seq": g.seq,
118        "ts_ms": ts_ms,
119        "stamp": stamp,
120        "span": span,
121        "phase": phase,
122        // Serialize the payload through serde_json::to_value so a failure to
123        // serialize degrades to null instead of dropping the whole event.
124        "data": serde_json::to_value(data).unwrap_or(serde_json::Value::Null),
125    });
126    if let Some(f) = g.file.as_mut() {
127        let _ = writeln!(f, "{line}");
128        let _ = f.flush();
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[derive(Serialize)]
137    struct Payload {
138        repo: String,
139        symbols: usize,
140    }
141
142    /// inject-assert: emit a handful of events to a `$FACETT_TRACE` override path,
143    /// read the JSONL back, and assert the seq / ts_ms / stamp / span / phase /
144    /// data contract fields — proving the typed payload round-trips as REAL
145    /// structured data (not a string), and that the env-var path override is honoured.
146    ///
147    /// Note: the sink is a process-global `OnceLock`, so the `$FACETT_TRACE` path
148    /// it picks up is whichever was set before its FIRST `emit`. This test is the
149    /// only one in the crate that emits, so it owns that first init.
150    #[test]
151    fn writes_jsonl_lines_with_real_payload_to_env_path() {
152        let dir = tempfile::tempdir().unwrap();
153        let path = dir.path().join("facett_trace_test.jsonl");
154        // edition 2024: env mutation is `unsafe` (single-threaded test, fine).
155        unsafe { std::env::set_var("FACETT_TRACE", &path) };
156
157        emit_in("knowledge.scan", &serde_json::json!({ "repos": ["njord"] }));
158        emit_end("knowledge.scan.repo", &Payload { repo: "njord".into(), symbols: 642 });
159        emit_event("ui.tab", &serde_json::json!({ "tab": "Bench" }));
160
161        let body = std::fs::read_to_string(&path).expect("the env-var override path was written");
162        let lines: Vec<&str> = body.lines().collect();
163        // session header + the three emits.
164        assert!(lines.len() >= 4, "expected >=4 lines, got {}", lines.len());
165
166        // Every line is valid JSON carrying the full contract.
167        let mut last_seq = 0u64;
168        for l in &lines {
169            let v: serde_json::Value = serde_json::from_str(l).unwrap();
170            assert!(v.get("seq").is_some(), "seq present");
171            assert!(v.get("ts_ms").and_then(|t| t.as_u64()).map(|t| t > 0).unwrap_or(false), "ts_ms is a real epoch-ms");
172            assert!(v.get("stamp").and_then(|s| s.as_str()).map(|s| s.len() >= 8).unwrap_or(false), "stamp is HH:MM:SS.mmm");
173            assert!(v.get("span").is_some(), "span present");
174            assert!(v.get("phase").is_some(), "phase present");
175            assert!(v.get("data").is_some(), "data present");
176            // seq is strictly monotonic.
177            let seq = v["seq"].as_u64().unwrap();
178            assert!(seq > last_seq, "seq strictly increases: {seq} after {last_seq}");
179            last_seq = seq;
180        }
181
182        // The first emitted line is the session header pointing at our path.
183        let header: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
184        assert_eq!(header["span"], "trace.session");
185        assert_eq!(header["phase"], "event");
186        assert_eq!(header["data"]["file"], path.to_str().unwrap());
187
188        // The repo payload round-trips as real structured data, not a string —
189        // and its phase serialises lowercase per the contract.
190        let repo_line = lines.iter().map(|l| serde_json::from_str::<serde_json::Value>(l).unwrap())
191            .find(|v| v["span"] == "knowledge.scan.repo").unwrap();
192        assert_eq!(repo_line["phase"], "end");
193        assert_eq!(repo_line["data"]["symbols"], 642);
194        assert_eq!(repo_line["data"]["repo"], "njord");
195
196        // The `in` and `event` phases are present and lowercase too.
197        let scan = lines.iter().map(|l| serde_json::from_str::<serde_json::Value>(l).unwrap())
198            .find(|v| v["span"] == "knowledge.scan").unwrap();
199        assert_eq!(scan["phase"], "in");
200        assert_eq!(scan["data"]["repos"], serde_json::json!(["njord"]));
201        let tab = lines.iter().map(|l| serde_json::from_str::<serde_json::Value>(l).unwrap())
202            .find(|v| v["span"] == "ui.tab").unwrap();
203        assert_eq!(tab["phase"], "event");
204        assert_eq!(tab["data"]["tab"], "Bench");
205    }
206}