use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
use std::sync::{Mutex, OnceLock};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ConvergenceEvent {
SccBatch(SccBatchRecord),
InFilePass2(InFilePass2Record),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SccBatchRecord {
pub schema: u32,
pub batch_index: usize,
pub file_count: usize,
pub cross_file: bool,
pub iterations: usize,
pub cap: usize,
pub converged: bool,
pub trajectory: SmallVec<[u32; 4]>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct InFilePass2Record {
pub schema: u32,
pub namespace: String,
pub body_count: usize,
pub iterations: usize,
pub cap: usize,
pub converged: bool,
pub trajectory: SmallVec<[u32; 4]>,
}
static COLLECTOR: OnceLock<Mutex<Vec<ConvergenceEvent>>> = OnceLock::new();
pub fn is_enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| match std::env::var("NYX_CONVERGENCE_TELEMETRY") {
Ok(v) => !matches!(v.as_str(), "" | "0" | "false"),
Err(_) => false,
})
}
pub fn record(event: ConvergenceEvent) {
if !is_enabled() {
return;
}
let lock = COLLECTOR.get_or_init(|| Mutex::new(Vec::new()));
if let Ok(mut guard) = lock.lock() {
guard.push(event);
}
}
pub fn drain() -> Vec<ConvergenceEvent> {
let Some(lock) = COLLECTOR.get() else {
return Vec::new();
};
match lock.lock() {
Ok(mut guard) => std::mem::take(&mut *guard),
Err(_) => Vec::new(),
}
}
pub fn write_jsonl(path: &std::path::Path) -> std::io::Result<usize> {
use std::io::Write;
let events = drain();
if events.is_empty() {
return Ok(0);
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
for event in &events {
let line = serde_json::to_string(event).map_err(std::io::Error::other)?;
writeln!(file, "{line}")?;
}
file.flush()?;
Ok(events.len())
}
pub fn default_path(scan_root: &std::path::Path) -> std::path::PathBuf {
if let Ok(explicit) = std::env::var("NYX_CONVERGENCE_TELEMETRY_PATH") {
return std::path::PathBuf::from(explicit);
}
scan_root.join("nyx-convergence.jsonl")
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static COLLECTOR_TEST_GUARD: Mutex<()> = Mutex::new(());
fn reset_and_enable_telemetry() {
let _ = drain();
}
#[test]
fn scc_batch_record_serializes_snake_case_tag() {
let event = ConvergenceEvent::SccBatch(SccBatchRecord {
schema: SCHEMA_VERSION,
batch_index: 3,
file_count: 7,
cross_file: true,
iterations: 12,
cap: 64,
converged: true,
trajectory: SmallVec::from_slice(&[8, 4, 2, 0]),
});
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"kind\":\"scc_batch\""), "got {json}");
assert!(json.contains("\"cross_file\":true"), "got {json}");
assert!(json.contains("\"converged\":true"), "got {json}");
}
#[test]
fn in_file_pass2_record_serializes_snake_case_tag() {
let event = ConvergenceEvent::InFilePass2(InFilePass2Record {
schema: SCHEMA_VERSION,
namespace: "src/foo.js".into(),
body_count: 42,
iterations: 5,
cap: 64,
converged: true,
trajectory: SmallVec::from_slice(&[10, 3, 1, 0]),
});
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"kind\":\"in_file_pass2\""), "got {json}");
assert!(json.contains("\"namespace\":\"src/foo.js\""), "got {json}");
}
#[test]
fn jsonl_roundtrip_via_tempfile() {
let _guard = COLLECTOR_TEST_GUARD
.lock()
.unwrap_or_else(|e| e.into_inner());
reset_and_enable_telemetry();
let lock = COLLECTOR.get_or_init(|| Mutex::new(Vec::new()));
{
let mut g = lock.lock().unwrap();
g.push(ConvergenceEvent::SccBatch(SccBatchRecord {
schema: SCHEMA_VERSION,
batch_index: 0,
file_count: 1,
cross_file: false,
iterations: 1,
cap: 64,
converged: true,
trajectory: SmallVec::new(),
}));
}
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("conv.jsonl");
let written = write_jsonl(&path).unwrap();
assert_eq!(written, 1);
let content = std::fs::read_to_string(&path).unwrap();
let line = content.trim();
let parsed: ConvergenceEvent = serde_json::from_str(line).unwrap();
match parsed {
ConvergenceEvent::SccBatch(r) => {
assert_eq!(r.iterations, 1);
assert!(r.converged);
}
_ => panic!("expected SccBatch"),
}
}
#[test]
fn drain_empties_collector() {
let _guard = COLLECTOR_TEST_GUARD
.lock()
.unwrap_or_else(|e| e.into_inner());
reset_and_enable_telemetry();
let lock = COLLECTOR.get_or_init(|| Mutex::new(Vec::new()));
{
let mut g = lock.lock().unwrap();
g.push(ConvergenceEvent::InFilePass2(InFilePass2Record {
schema: SCHEMA_VERSION,
namespace: "x".into(),
body_count: 0,
iterations: 0,
cap: 0,
converged: true,
trajectory: SmallVec::new(),
}));
}
let e1 = drain();
let e2 = drain();
assert_eq!(e1.len(), 1);
assert_eq!(e2.len(), 0);
}
#[test]
fn default_path_honors_env_override() {
let root = std::path::Path::new("/tmp/nyx-test");
let p = default_path(root);
assert!(
p.to_string_lossy().contains("nyx-convergence.jsonl"),
"got {p:?}"
);
}
}