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}