Skip to main content

nyx_agent_core/
log_init.rs

1//! `tracing` initialisation shared by every binary entry point.
2//!
3//! Two layers are installed:
4//!
5//! * a JSON layer writing one event per line to `<state>/logs/agent.jsonl`,
6//!   used downstream by replay and CI inspection tools;
7//! * a human-readable layer writing to stderr, gated by `--log-level` so
8//!   stdout stays clean for tools that pipe agent output.
9//!
10//! Span fields `run_id`, `repo`, `task_id`, `prompt_version` are surfaced
11//! by every later phase. Tracing records whichever of those are present
12//! on the current span; nothing here forces them.
13
14use std::fs::OpenOptions;
15use std::io::{self, Write};
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18
19use thiserror::Error;
20use tracing_subscriber::fmt::writer::BoxMakeWriter;
21use tracing_subscriber::layer::SubscriberExt;
22use tracing_subscriber::util::SubscriberInitExt;
23use tracing_subscriber::EnvFilter;
24
25use crate::secrets::looks_like_secret;
26
27#[derive(Debug, Error)]
28pub enum LogInitError {
29    #[error("failed to open log file at {path}: {source}")]
30    OpenLog {
31        path: PathBuf,
32        #[source]
33        source: std::io::Error,
34    },
35    #[error("invalid log level {0:?}")]
36    InvalidLevel(String),
37    #[error("global tracing subscriber already installed")]
38    AlreadyInstalled,
39}
40
41#[derive(Debug, Clone)]
42pub struct LogConfig {
43    pub log_dir: PathBuf,
44    pub level: String,
45    pub file_name: String,
46}
47
48impl LogConfig {
49    pub fn new(log_dir: impl Into<PathBuf>, level: impl Into<String>) -> Self {
50        Self { log_dir: log_dir.into(), level: level.into(), file_name: "agent.jsonl".to_string() }
51    }
52}
53
54/// Install the global subscriber. Returns an error if the global
55/// subscriber is already set or the JSON log file cannot be opened.
56pub fn init(cfg: &LogConfig) -> Result<(), LogInitError> {
57    install(cfg, std::io::stderr)
58}
59
60/// Variant that lets callers redirect the human-readable layer (used by
61/// tests). The JSON layer always writes to `<log_dir>/<file_name>`.
62#[tracing::instrument(skip_all, fields(level = %cfg.level, log_dir = %cfg.log_dir.display()))]
63pub fn install<W>(cfg: &LogConfig, human_writer: W) -> Result<(), LogInitError>
64where
65    W: for<'a> tracing_subscriber::fmt::MakeWriter<'a> + Send + Sync + 'static,
66{
67    let env_filter = EnvFilter::try_new(&cfg.level)
68        .map_err(|_| LogInitError::InvalidLevel(cfg.level.clone()))?;
69
70    std::fs::create_dir_all(&cfg.log_dir)
71        .map_err(|source| LogInitError::OpenLog { path: cfg.log_dir.clone(), source })?;
72    let log_path = cfg.log_dir.join(&cfg.file_name);
73    let file = OpenOptions::new()
74        .create(true)
75        .append(true)
76        .open(&log_path)
77        .map_err(|source| LogInitError::OpenLog { path: log_path.clone(), source })?;
78    let file = Arc::new(file);
79    let json_writer = BoxMakeWriter::new(move || -> Box<dyn io::Write + Send> {
80        Box::new(RedactingWriter::new(FileHandle(Arc::clone(&file))))
81    });
82
83    let json_layer = tracing_subscriber::fmt::layer()
84        .json()
85        .with_current_span(true)
86        .with_span_list(true)
87        .with_target(true)
88        .with_writer(json_writer);
89
90    // Wrap the human-readable layer's writer in the same redactor so a
91    // stray `tracing::info!(token = %secret)` does not leak via stderr
92    // either. `MakeWriter` is hand-implemented because the std closure
93    // signature does not satisfy the `Sync` bound on its own.
94    let human_writer = RedactingMakeWriter::new(human_writer);
95    let human_layer = tracing_subscriber::fmt::layer().with_target(false).with_writer(human_writer);
96
97    tracing_subscriber::registry()
98        .with(env_filter)
99        .with(json_layer)
100        .with(human_layer)
101        .try_init()
102        .map_err(|_| LogInitError::AlreadyInstalled)
103}
104
105/// Inspect-only helper used by `nyx-agent doctor` so it can confirm the
106/// JSON sink path without actually installing a subscriber.
107pub fn json_log_path(log_dir: &Path) -> PathBuf {
108    log_dir.join("agent.jsonl")
109}
110
111struct FileHandle(Arc<std::fs::File>);
112
113impl io::Write for FileHandle {
114    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
115        (&*self.0).write(buf)
116    }
117
118    fn flush(&mut self) -> io::Result<()> {
119        (&*self.0).flush()
120    }
121}
122
123/// Replacement bytes substituted in place of anything matching a known
124/// secret pattern.
125const REDACTED: &[u8] = b"<redacted>";
126
127/// `Write` wrapper that scans every line for token-shaped substrings and
128/// rewrites them to `<redacted>` before forwarding. Cheap by design:
129/// the inner buffer is bounded to one log line, and the matching is a
130/// linear pass through ASCII-shaped runs of `[A-Za-z0-9_\-]`.
131pub(crate) struct RedactingWriter<W: io::Write> {
132    inner: W,
133    buf: Vec<u8>,
134}
135
136impl<W: io::Write> RedactingWriter<W> {
137    pub(crate) fn new(inner: W) -> Self {
138        Self { inner, buf: Vec::with_capacity(512) }
139    }
140}
141
142impl<W: io::Write> io::Write for RedactingWriter<W> {
143    fn write(&mut self, data: &[u8]) -> io::Result<usize> {
144        self.buf.extend_from_slice(data);
145        while let Some(idx) = self.buf.iter().position(|b| *b == b'\n') {
146            let line: Vec<u8> = self.buf.drain(..=idx).collect();
147            let scrubbed = redact_line(&line);
148            self.inner.write_all(&scrubbed)?;
149        }
150        Ok(data.len())
151    }
152
153    fn flush(&mut self) -> io::Result<()> {
154        if !self.buf.is_empty() {
155            let scrubbed = redact_line(&self.buf);
156            self.inner.write_all(&scrubbed)?;
157            self.buf.clear();
158        }
159        self.inner.flush()
160    }
161}
162
163impl<W: io::Write> Drop for RedactingWriter<W> {
164    fn drop(&mut self) {
165        let _ = self.flush();
166    }
167}
168
169fn redact_line(line: &[u8]) -> Vec<u8> {
170    let Ok(s) = std::str::from_utf8(line) else {
171        // Non-UTF8 log lines are unexpected; pass them through verbatim
172        // because the redactor can only reason about ASCII tokens.
173        return line.to_vec();
174    };
175    let mut out = String::with_capacity(s.len());
176    let mut start = 0;
177    let bytes = s.as_bytes();
178    let mut i = 0;
179    while i < bytes.len() {
180        let c = bytes[i];
181        if is_token_char(c) {
182            let mut end = i + 1;
183            while end < bytes.len() && is_token_char(bytes[end]) {
184                end += 1;
185            }
186            let candidate = &s[i..end];
187            if looks_like_secret(candidate) {
188                out.push_str(&s[start..i]);
189                out.push_str(std::str::from_utf8(REDACTED).unwrap());
190                start = end;
191            }
192            i = end;
193        } else {
194            i += 1;
195        }
196    }
197    out.push_str(&s[start..]);
198    out.into_bytes()
199}
200
201fn is_token_char(b: u8) -> bool {
202    b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
203}
204
205#[derive(Clone)]
206struct RedactingMakeWriter<W> {
207    inner: W,
208}
209
210impl<W> RedactingMakeWriter<W> {
211    fn new(inner: W) -> Self {
212        Self { inner }
213    }
214}
215
216impl<'a, W> tracing_subscriber::fmt::MakeWriter<'a> for RedactingMakeWriter<W>
217where
218    W: tracing_subscriber::fmt::MakeWriter<'a> + 'static,
219{
220    type Writer = RedactingWriter<W::Writer>;
221    fn make_writer(&'a self) -> Self::Writer {
222        RedactingWriter::new(self.inner.make_writer())
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn json_log_path_appends_filename() {
232        assert_eq!(
233            json_log_path(Path::new("/var/state/logs")),
234            PathBuf::from("/var/state/logs/agent.jsonl")
235        );
236    }
237
238    #[test]
239    fn redact_line_replaces_anthropic_key_shape() {
240        let scrubbed = redact_line(b"calling api with token=sk-ant-api03-aaaaabbbbbcccccddddd\n");
241        let out = String::from_utf8(scrubbed).unwrap();
242        assert!(!out.contains("sk-ant"), "redacted output still contains secret: {out:?}");
243        assert!(out.contains("<redacted>"));
244    }
245
246    #[test]
247    fn redact_line_leaves_non_secret_text_intact() {
248        let input = b"GET /api/v1/health status=200\n";
249        let scrubbed = redact_line(input);
250        assert_eq!(scrubbed, input);
251    }
252
253    #[test]
254    fn install_writes_json_file_and_is_idempotent_in_failure() {
255        let tmp = tempfile::tempdir().expect("tempdir");
256        let cfg = LogConfig::new(tmp.path(), "info");
257        // First install in this test process succeeds; a second call must
258        // fail rather than silently double-register the global subscriber.
259        // We can't rely on ordering across tests, so guard with try_init's
260        // own contract: either branch is acceptable.
261        let first = install(&cfg, std::io::sink);
262        let second = install(&cfg, std::io::sink);
263        assert!(first.is_ok() || matches!(first, Err(LogInitError::AlreadyInstalled)));
264        assert!(matches!(second, Err(LogInitError::AlreadyInstalled)));
265        let log_path = json_log_path(tmp.path());
266        assert!(log_path.exists(), "{} should exist", log_path.display());
267    }
268}