nornir 0.4.20

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Structured event trace — the **machine-readable** sibling of `action_log`.
//!
//! `action_log` is a human free-form trail (`tab=Bench`, `reload … → 3
//! release(s)`). This is its typed counterpart: every instrumented operation
//! emits its INPUT parameters and its OUTPUT *data* as real JSON payloads, so an
//! external agent (or the headless test matrix) can read back **exactly the data
//! the UI rendered** — the bubble table, the bench rows, the server response —
//! not a summary and not a screenshot.
//!
//! One JSON object per line (JSONL) at `$NORNIR_VIZ_TRACE` (default
//! `/tmp/nornir_viz_trace.jsonl`), truncated on launch:
//!
//! ```json
//! {"seq":7,"ts_ms":1733961825678,"stamp":"01:23:45.678",
//!  "span":"knowledge.scan","phase":"in","data":{"workspace_root":"…","repos":["njord"]}}
//! {"seq":8,...,"span":"knowledge.scan.repo","phase":"end","data":{"repo":"njord","symbols":642,…}}
//! {"seq":9,...,"span":"knowledge.scan","phase":"out","data":{"repos_ok":1,"repos_total":1,"ms":83}}
//! {"seq":10,...,"span":"knowledge.render","phase":"end","data":{"bubbles":[{"repo":"njord",…}]}}
//! ```
//!
//! ## The contract
//! - **span** — the operation name (`knowledge.scan`, `ui.tab`, `server.reload`).
//! - **phase** — `in` (params accepted), `out` (result produced), `end` (a unit
//!   of work finished), or `event` (a point-in-time fact, e.g. a tab switch).
//!   Pair an `in` with its later `out`/`end` of the same `span` by adjacency —
//!   the UI is single-threaded, so within one operation they don't interleave.
//! - **data** — the real, structured payload. Emit typed structs (serialized via
//!   serde), never a pre-formatted string, so the consumer parses fields.
//!
//! Cheap by design: a `Mutex<File>` `writeln!` + a global atomic seq. Call sites
//! only emit on edge-triggered events (a scan, a click, an RPC), never per-frame.

use std::io::Write;
use std::sync::{Mutex, OnceLock};

use serde::Serialize;

/// Phase of an instrumented operation. Lets a consumer correlate the inputs a
/// call accepted with the data it produced.
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Phase {
    /// Inputs accepted — the parameters that drive the operation.
    In,
    /// Output produced — the result data the operation returns.
    Out,
    /// A unit of work finished (use when there's no separate in/out split).
    End,
    /// A point-in-time fact with no duration (a tab switch, a selection).
    Event,
}

struct Sink {
    inner: Mutex<Inner>,
    path: String,
}

struct Inner {
    seq: u64,
    file: Option<std::fs::File>,
}

static SINK: OnceLock<Sink> = OnceLock::new();

fn sink() -> &'static Sink {
    SINK.get_or_init(|| {
        let path = std::env::var("NORNIR_VIZ_TRACE")
            .unwrap_or_else(|_| "/tmp/nornir_viz_trace.jsonl".to_string());
        // Truncate on launch so each session's trace starts clean (matches the
        // single-snapshot semantics of $NORNIR_VIZ_STATE / the action log file).
        let file = std::fs::File::create(&path).ok();
        let s = Sink { inner: Mutex::new(Inner { seq: 0, file }), path };
        // A session header so the consumer knows the trace (re)started and where.
        emit_into(&s, "trace.session", Phase::Event, &serde_json::json!({ "file": s.path }));
        s
    })
}

/// Emit one structured event: `span` is the operation, `phase` its stage, and
/// `data` the real payload (any `Serialize` — a typed struct, ideally). Writes a
/// single JSONL line to `$NORNIR_VIZ_TRACE`; never panics (best-effort I/O).
pub fn emit<T: Serialize>(span: &str, phase: Phase, data: &T) {
    emit_into(sink(), span, phase, data);
}

/// Convenience: the inputs a call accepted.
pub fn emit_in<T: Serialize>(span: &str, data: &T) {
    emit(span, Phase::In, data);
}

/// Convenience: the data a call produced.
pub fn emit_out<T: Serialize>(span: &str, data: &T) {
    emit(span, Phase::Out, data);
}

/// Convenience: a unit of work finished (no separate in/out).
pub fn emit_end<T: Serialize>(span: &str, data: &T) {
    emit(span, Phase::End, data);
}

/// Convenience: a point-in-time fact (tab switch, selection).
pub fn emit_event<T: Serialize>(span: &str, data: &T) {
    emit(span, Phase::Event, data);
}

fn emit_into<T: Serialize>(s: &Sink, span: &str, phase: Phase, data: &T) {
    let now = chrono::Local::now();
    let stamp = now.format("%H:%M:%S%.3f").to_string();
    let ts_ms = now.timestamp_millis();
    let Ok(mut g) = s.inner.lock() else { return };
    g.seq += 1;
    let line = serde_json::json!({
        "seq": g.seq,
        "ts_ms": ts_ms,
        "stamp": stamp,
        "span": span,
        "phase": phase,
        // Serialize the payload through serde_json::to_value so a failure to
        // serialize degrades to null instead of dropping the whole event.
        "data": serde_json::to_value(data).unwrap_or(serde_json::Value::Null),
    });
    if let Some(f) = g.file.as_mut() {
        let _ = writeln!(f, "{line}");
        let _ = f.flush();
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[derive(Serialize)]
    struct Payload {
        repo: String,
        symbols: usize,
    }

    #[test]
    fn writes_jsonl_lines_with_real_payload() {
        let path = "/tmp/nornir_viz_trace_test.jsonl";
        std::env::set_var("NORNIR_VIZ_TRACE", path);
        emit_in("knowledge.scan", &serde_json::json!({ "repos": ["njord"] }));
        emit_end("knowledge.scan.repo", &Payload { repo: "njord".into(), symbols: 642 });
        let body = std::fs::read_to_string(path).unwrap();
        let lines: Vec<&str> = body.lines().collect();
        // session header + the two emits.
        assert!(lines.len() >= 3, "expected >=3 lines, got {}", lines.len());
        // Every line is valid JSON with the contract fields.
        for l in &lines {
            let v: serde_json::Value = serde_json::from_str(l).unwrap();
            assert!(v.get("seq").is_some());
            assert!(v.get("span").is_some());
            assert!(v.get("phase").is_some());
            assert!(v.get("data").is_some());
        }
        // The repo payload round-trips as real structured data, not a string.
        let last: serde_json::Value = serde_json::from_str(lines.last().unwrap()).unwrap();
        assert_eq!(last["span"], "knowledge.scan.repo");
        assert_eq!(last["phase"], "end");
        assert_eq!(last["data"]["symbols"], 642);
    }
}