nyx_agent_core/
log_init.rs1use 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
54pub fn init(cfg: &LogConfig) -> Result<(), LogInitError> {
57 install(cfg, std::io::stderr)
58}
59
60#[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 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
105pub 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
123const REDACTED: &[u8] = b"<redacted>";
126
127pub(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 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 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}