Skip to main content

facett_core/
trace.rs

1//! Structured event trace — the **machine-readable** sibling of
2//! [`harness::trail`](crate::harness::trail).
3//!
4//! `harness::trail` is a human free-form line (`facett ACTION … [RENDER] tiny
5//! size=800x600 → 412 verts`). This is its typed counterpart: every
6//! instrumented operation emits its INPUT parameters and its OUTPUT *data* as
7//! real JSON payloads, so an external agent (or a test matrix) can read back
8//! **exactly the data a facet rendered** — its `state_json`, the vertex proof,
9//! the inputs it was handed — not a summary and not a screenshot.
10//!
11//! Mirrors nornir viz's `$NORNIR_VIZ_TRACE`: one JSON object per line (JSONL) at
12//! `$FACETT_TRACE` (default `/tmp/facett_trace.jsonl`), truncated on launch:
13//!
14//! ```json
15//! {"seq":3,"ts_ms":1733961825678,"stamp":"01:23:45.678",
16//!  "span":"facet.render","phase":"in","data":{"title":"graph","size":[800.0,600.0]}}
17//! {"seq":4,...,"span":"facet.render","phase":"out","data":{"title":"graph","vertices":412,"drew":true,"state":{…}}}
18//! ```
19//!
20//! ## The contract
21//! - **span** — the operation name (`facet.render`, and whatever a live facet
22//!   emits for its own interactions).
23//! - **phase** — `in` (params accepted), `out` (result produced), `end` (a unit
24//!   of work finished), or `event` (a point-in-time fact). Pair an `in` with its
25//!   later `out`/`end` of the same `span` by adjacency.
26//! - **data** — the real, structured payload (a `serde_json::Value`, the same
27//!   currency as [`Facet::state_json`](crate::Facet::state_json)), so the
28//!   consumer parses fields rather than scraping a formatted string.
29//!
30//! Dependency-free, like the rest of `harness`: the stamp comes from
31//! `SystemTime` (no chrono) and the payload is a `serde_json::Value` (no
32//! serde-derive). Cheap: a `Mutex<File>` `writeln!` + a global atomic seq. Call
33//! sites emit on edge-triggered events only, never per-frame.
34
35use std::io::Write;
36use std::sync::{Mutex, OnceLock};
37
38/// Phase of an instrumented operation — lets a consumer correlate the inputs a
39/// call accepted with the data it produced.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum Phase {
42    /// Inputs accepted — the parameters that drive the operation.
43    In,
44    /// Output produced — the result data the operation returns.
45    Out,
46    /// A unit of work finished (use when there's no separate in/out split).
47    End,
48    /// A point-in-time fact with no duration.
49    Event,
50}
51
52impl Phase {
53    fn as_str(self) -> &'static str {
54        match self {
55            Phase::In => "in",
56            Phase::Out => "out",
57            Phase::End => "end",
58            Phase::Event => "event",
59        }
60    }
61}
62
63struct Sink {
64    inner: Mutex<Inner>,
65    path: String,
66}
67
68struct Inner {
69    seq: u64,
70    file: Option<std::fs::File>,
71}
72
73static SINK: OnceLock<Sink> = OnceLock::new();
74
75fn sink() -> &'static Sink {
76    SINK.get_or_init(|| {
77        let path =
78            std::env::var("FACETT_TRACE").unwrap_or_else(|_| "/tmp/facett_trace.jsonl".to_string());
79        // Truncate on launch so each session's trace starts clean (matches the
80        // single-snapshot semantics of the rest of the harness sinks).
81        let file = std::fs::File::create(&path).ok();
82        let s = Sink { inner: Mutex::new(Inner { seq: 0, file }), path };
83        emit_into(&s, "trace.session", Phase::Event, &serde_json::json!({ "file": s.path }));
84        s
85    })
86}
87
88/// Emit one structured event: `span` is the operation, `phase` its stage, and
89/// `data` the real payload (a `serde_json::Value` — typically a facet's
90/// `state_json` or a small `json!({…})` of its inputs). Writes a single JSONL
91/// line to `$FACETT_TRACE`; never panics (best-effort I/O).
92pub fn emit(span: &str, phase: Phase, data: &serde_json::Value) {
93    emit_into(sink(), span, phase, data);
94}
95
96/// Convenience: the inputs a call accepted.
97pub fn emit_in(span: &str, data: &serde_json::Value) {
98    emit(span, Phase::In, data);
99}
100
101/// Convenience: the data a call produced.
102pub fn emit_out(span: &str, data: &serde_json::Value) {
103    emit(span, Phase::Out, data);
104}
105
106/// Convenience: a unit of work finished (no separate in/out).
107pub fn emit_end(span: &str, data: &serde_json::Value) {
108    emit(span, Phase::End, data);
109}
110
111/// Convenience: a point-in-time fact (a selection, a mode switch).
112pub fn emit_event(span: &str, data: &serde_json::Value) {
113    emit(span, Phase::Event, data);
114}
115
116fn emit_into(s: &Sink, span: &str, phase: Phase, data: &serde_json::Value) {
117    let now = std::time::SystemTime::now()
118        .duration_since(std::time::UNIX_EPOCH)
119        .unwrap_or_default();
120    let ts_ms = now.as_millis() as u64;
121    let stamp = stamp_from_ms(ts_ms);
122    let Ok(mut g) = s.inner.lock() else { return };
123    g.seq += 1;
124    let line = serde_json::json!({
125        "seq": g.seq,
126        "ts_ms": ts_ms,
127        "stamp": stamp,
128        "span": span,
129        "phase": phase.as_str(),
130        "data": data,
131    });
132    if let Some(f) = g.file.as_mut() {
133        let _ = writeln!(f, "{line}");
134        let _ = f.flush();
135    }
136}
137
138/// `HH:MM:SS.mmm` (UTC, no tz dep) — only the time-of-day matters to follow a
139/// trace, so this is intentionally dependency-free rather than chrono-accurate
140/// (matches `harness::now_stamp`).
141fn stamp_from_ms(total_ms: u64) -> String {
142    let ms = total_ms % 1000;
143    let secs = total_ms / 1000;
144    let h = (secs / 3600) % 24;
145    let m = (secs / 60) % 60;
146    let s = secs % 60;
147    format!("{h:02}:{m:02}:{s:02}.{ms:03}")
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn writes_jsonl_lines_with_real_payload() {
156        let path = "/tmp/facett_trace_test.jsonl";
157        // edition 2024: env mutation is `unsafe` (single-threaded test, fine).
158        unsafe { std::env::set_var("FACETT_TRACE", path) };
159        emit_in("facet.render", &serde_json::json!({ "title": "graph", "size": [800.0, 600.0] }));
160        emit_out(
161            "facet.render",
162            &serde_json::json!({ "title": "graph", "vertices": 412, "drew": true }),
163        );
164        let body = std::fs::read_to_string(path).unwrap();
165        let lines: Vec<&str> = body.lines().collect();
166        // session header + the two emits.
167        assert!(lines.len() >= 3, "expected >=3 lines, got {}", lines.len());
168        for l in &lines {
169            let v: serde_json::Value = serde_json::from_str(l).unwrap();
170            assert!(v.get("seq").is_some());
171            assert!(v.get("span").is_some());
172            assert!(v.get("phase").is_some());
173            assert!(v.get("data").is_some());
174        }
175        // The render payload round-trips as real structured data, not a string.
176        let last: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
177        assert_eq!(last["span"], "facet.render");
178        assert_eq!(last["phase"], "out");
179        assert_eq!(last["data"]["vertices"], 412);
180    }
181
182    #[test]
183    fn stamp_shape_is_hms_millis() {
184        let s = stamp_from_ms(3_661_123); // 01:01:01.123
185        assert_eq!(s, "01:01:01.123");
186    }
187}