Skip to main content

tj_core/classifier/
telemetry.rs

1//! Append-only classifier telemetry: one JSONL line per classification call.
2
3use anyhow::Context;
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct TelemetryRecord {
9    pub timestamp: String,
10    pub project_hash: String,
11    pub task_id_guess: Option<String>,
12    pub event_type: String,
13    pub confidence: f64,
14    pub status: String,
15    pub error: Option<String>,
16}
17
18pub fn append(metrics_path: impl AsRef<Path>, record: &TelemetryRecord) -> anyhow::Result<()> {
19    if let Some(parent) = metrics_path.as_ref().parent() {
20        std::fs::create_dir_all(parent)?;
21    }
22    let line = serde_json::to_string(record).context("serialize telemetry")?;
23    use std::io::Write;
24    let mut f = std::fs::OpenOptions::new()
25        .create(true)
26        .append(true)
27        .open(&metrics_path)?;
28    writeln!(f, "{line}")?;
29    Ok(())
30}
31
32#[cfg(test)]
33mod tests {
34    use super::*;
35    use tempfile::TempDir;
36
37    #[test]
38    fn append_and_read_back_roundtrip() {
39        let d = TempDir::new().unwrap();
40        let path = d.path().join("metrics.jsonl");
41
42        let r1 = TelemetryRecord {
43            timestamp: "2026-04-30T00:00:00Z".into(),
44            project_hash: "feedface".into(),
45            task_id_guess: Some("tj-x".into()),
46            event_type: "decision".into(),
47            confidence: 0.92,
48            status: "confirmed".into(),
49            error: None,
50        };
51        let r2 = TelemetryRecord {
52            confidence: 0.4,
53            status: "suggested".into(),
54            ..r1.clone()
55        };
56        append(&path, &r1).unwrap();
57        append(&path, &r2).unwrap();
58
59        let body = std::fs::read_to_string(&path).unwrap();
60        assert_eq!(body.lines().count(), 2);
61    }
62}