use std::io::Write;
use std::sync::{Mutex, OnceLock};
use serde::Serialize;
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Phase {
In,
Out,
End,
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("FACETT_TRACE")
.unwrap_or_else(|_| "/tmp/facett_trace.jsonl".to_string());
let file = std::fs::File::create(&path).ok();
let s = Sink { inner: Mutex::new(Inner { seq: 0, file }), path };
emit_into(&s, "trace.session", Phase::Event, &serde_json::json!({ "file": s.path }));
s
})
}
pub fn emit<T: Serialize>(span: &str, phase: Phase, data: &T) {
emit_into(sink(), span, phase, data);
}
pub fn emit_in<T: Serialize>(span: &str, data: &T) {
emit(span, Phase::In, data);
}
pub fn emit_out<T: Serialize>(span: &str, data: &T) {
emit(span, Phase::Out, data);
}
pub fn emit_end<T: Serialize>(span: &str, data: &T) {
emit(span, Phase::End, data);
}
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,
"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_to_env_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("facett_trace_test.jsonl");
unsafe { std::env::set_var("FACETT_TRACE", &path) };
emit_in("knowledge.scan", &serde_json::json!({ "repos": ["njord"] }));
emit_end("knowledge.scan.repo", &Payload { repo: "njord".into(), symbols: 642 });
emit_event("ui.tab", &serde_json::json!({ "tab": "Bench" }));
let body = std::fs::read_to_string(&path).expect("the env-var override path was written");
let lines: Vec<&str> = body.lines().collect();
assert!(lines.len() >= 4, "expected >=4 lines, got {}", lines.len());
let mut last_seq = 0u64;
for l in &lines {
let v: serde_json::Value = serde_json::from_str(l).unwrap();
assert!(v.get("seq").is_some(), "seq present");
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");
assert!(v.get("stamp").and_then(|s| s.as_str()).map(|s| s.len() >= 8).unwrap_or(false), "stamp is HH:MM:SS.mmm");
assert!(v.get("span").is_some(), "span present");
assert!(v.get("phase").is_some(), "phase present");
assert!(v.get("data").is_some(), "data present");
let seq = v["seq"].as_u64().unwrap();
assert!(seq > last_seq, "seq strictly increases: {seq} after {last_seq}");
last_seq = seq;
}
let header: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(header["span"], "trace.session");
assert_eq!(header["phase"], "event");
assert_eq!(header["data"]["file"], path.to_str().unwrap());
let repo_line = lines.iter().map(|l| serde_json::from_str::<serde_json::Value>(l).unwrap())
.find(|v| v["span"] == "knowledge.scan.repo").unwrap();
assert_eq!(repo_line["phase"], "end");
assert_eq!(repo_line["data"]["symbols"], 642);
assert_eq!(repo_line["data"]["repo"], "njord");
let scan = lines.iter().map(|l| serde_json::from_str::<serde_json::Value>(l).unwrap())
.find(|v| v["span"] == "knowledge.scan").unwrap();
assert_eq!(scan["phase"], "in");
assert_eq!(scan["data"]["repos"], serde_json::json!(["njord"]));
let tab = lines.iter().map(|l| serde_json::from_str::<serde_json::Value>(l).unwrap())
.find(|v| v["span"] == "ui.tab").unwrap();
assert_eq!(tab["phase"], "event");
assert_eq!(tab["data"]["tab"], "Bench");
}
}