1use std::collections::BTreeMap;
22use std::fmt::Write as _;
23use std::fs::{File, OpenOptions};
24use std::io::{self, Write};
25use std::path::{Path, PathBuf};
26use std::sync::{Arc, Mutex};
27
28use chrono::Utc;
29use serde_json::{Value, json};
30use tracing::field::{Field, Visit};
31use tracing::{Event, Subscriber};
32use tracing_subscriber::Layer;
33use tracing_subscriber::layer::Context;
34use tracing_subscriber::registry::LookupSpan;
35
36use crate::redact::redact_in_place;
37
38pub const DEFAULT_ROTATE_BYTES: u64 = 16 * 1024 * 1024; pub const KEEP_GENERATIONS: u32 = 3;
44
45pub struct LogxWriter {
50 inner: Mutex<Inner>,
51}
52
53struct Inner {
54 dir: PathBuf,
55 base: String,
56 rotate_bytes: u64,
57 file: File,
58 written: u64,
59}
60
61impl LogxWriter {
62 pub fn open(dir: &Path, base: &str, rotate_bytes: u64) -> io::Result<Self> {
65 std::fs::create_dir_all(dir)?;
66 let path = log_path(dir, base, 0);
67 let file = OpenOptions::new().create(true).append(true).open(&path)?;
68 let written = file.metadata().map(|m| m.len()).unwrap_or(0);
69 Ok(Self {
70 inner: Mutex::new(Inner {
71 dir: dir.to_path_buf(),
72 base: base.to_string(),
73 rotate_bytes,
74 file,
75 written,
76 }),
77 })
78 }
79
80 pub fn write_record(&self, mut record: String) -> io::Result<()> {
84 redact_in_place(&mut record);
85 let mut bytes = record.into_bytes();
86 bytes.push(b'\n');
87
88 let mut inner = self
89 .inner
90 .lock()
91 .map_err(|_| io::Error::other("logx mutex poisoned"))?;
92
93 if inner.written.saturating_add(bytes.len() as u64) > inner.rotate_bytes
94 && inner.written > 0
95 {
96 inner.rotate()?;
97 }
98
99 inner.file.write_all(&bytes)?;
100 inner.written = inner.written.saturating_add(bytes.len() as u64);
101 Ok(())
102 }
103
104 pub fn rotate_now(&self) -> io::Result<()> {
106 let mut inner = self
107 .inner
108 .lock()
109 .map_err(|_| io::Error::other("logx mutex poisoned"))?;
110 inner.rotate()
111 }
112}
113
114impl Inner {
115 fn rotate(&mut self) -> io::Result<()> {
116 drop_file(&mut self.file);
119
120 let dir = self.dir.clone();
122 let base = self.base.clone();
123 delete_if_exists(&log_path(&dir, &base, KEEP_GENERATIONS))?;
124 for n in (1..KEEP_GENERATIONS).rev() {
125 let from = log_path(&dir, &base, n);
126 let to = log_path(&dir, &base, n + 1);
127 if from.exists() {
128 std::fs::rename(&from, &to)?;
129 }
130 }
131 let live = log_path(&dir, &base, 0);
132 let to = log_path(&dir, &base, 1);
133 if live.exists() {
134 std::fs::rename(&live, &to)?;
135 }
136
137 self.file = OpenOptions::new().create(true).append(true).open(&live)?;
138 self.written = 0;
139 Ok(())
140 }
141}
142
143fn drop_file(_f: &mut File) {
144 }
151
152fn delete_if_exists(path: &Path) -> io::Result<()> {
153 match std::fs::remove_file(path) {
154 Ok(()) => Ok(()),
155 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
156 Err(e) => Err(e),
157 }
158}
159
160fn log_path(dir: &Path, base: &str, generation: u32) -> PathBuf {
161 if generation == 0 {
162 dir.join(format!("{base}.ndjson"))
163 } else {
164 dir.join(format!("{base}.ndjson.{generation}"))
165 }
166}
167
168pub fn default_log_dir() -> PathBuf {
171 if let Ok(p) = std::env::var("INFERD_LOG_DIR") {
172 return PathBuf::from(p);
173 }
174 let home = dirs_home().unwrap_or_else(|| PathBuf::from("."));
175 home.join(".inferd").join("logs")
176}
177
178fn dirs_home() -> Option<PathBuf> {
179 #[cfg(unix)]
182 {
183 std::env::var_os("HOME").map(PathBuf::from)
184 }
185 #[cfg(not(unix))]
186 {
187 std::env::var_os("USERPROFILE").map(PathBuf::from)
188 }
189}
190
191pub struct LogxLayer {
203 writer: Arc<LogxWriter>,
204}
205
206impl LogxLayer {
207 pub fn new(writer: Arc<LogxWriter>) -> Self {
210 Self { writer }
211 }
212}
213
214impl<S> Layer<S> for LogxLayer
215where
216 S: Subscriber + for<'a> LookupSpan<'a>,
217{
218 fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
219 let metadata = event.metadata();
220 let mut visitor = JsonVisitor::default();
221 event.record(&mut visitor);
222
223 let message = visitor.fields.remove("message").map(value_to_string);
224
225 let mut record = serde_json::Map::new();
226 record.insert("t".into(), Value::String(Utc::now().to_rfc3339()));
227 record.insert(
228 "level".into(),
229 Value::String(metadata.level().to_string().to_lowercase()),
230 );
231 record.insert(
232 "component".into(),
233 Value::String(component_from_target(metadata.target())),
234 );
235 if let Some(msg) = message {
236 record.insert("msg".into(), Value::String(msg));
237 }
238 for (k, v) in visitor.fields {
239 record.insert(k, v);
240 }
241
242 let line = match serde_json::to_string(&Value::Object(record)) {
243 Ok(s) => s,
244 Err(e) => {
245 let mut buf = String::with_capacity(128);
248 let _ = write!(
249 buf,
250 r#"{{"t":"{}","level":"error","component":"logx","msg":"serialise: {}"}}"#,
251 Utc::now().to_rfc3339(),
252 e
253 );
254 buf
255 }
256 };
257 let _ = self.writer.write_record(line);
261 }
262}
263
264fn component_from_target(target: &str) -> String {
265 target
269 .split_once("::")
270 .map(|(_, rest)| rest.to_string())
271 .unwrap_or_else(|| target.to_string())
272}
273
274#[derive(Default)]
275struct JsonVisitor {
276 fields: BTreeMap<String, Value>,
277}
278
279impl Visit for JsonVisitor {
280 fn record_str(&mut self, field: &Field, value: &str) {
281 self.fields.insert(field.name().into(), json!(value));
282 }
283 fn record_bool(&mut self, field: &Field, value: bool) {
284 self.fields.insert(field.name().into(), json!(value));
285 }
286 fn record_i64(&mut self, field: &Field, value: i64) {
287 self.fields.insert(field.name().into(), json!(value));
288 }
289 fn record_u64(&mut self, field: &Field, value: u64) {
290 self.fields.insert(field.name().into(), json!(value));
291 }
292 fn record_f64(&mut self, field: &Field, value: f64) {
293 self.fields.insert(field.name().into(), json!(value));
294 }
295 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
296 self.fields
297 .insert(field.name().into(), json!(format!("{value:?}")));
298 }
299 fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
300 self.fields
301 .insert(field.name().into(), json!(value.to_string()));
302 }
303}
304
305fn value_to_string(v: Value) -> String {
306 match v {
307 Value::String(s) => s,
308 other => other.to_string(),
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use tempfile::tempdir;
316
317 fn read_file(path: &Path) -> String {
318 std::fs::read_to_string(path).unwrap()
319 }
320
321 #[test]
322 fn appends_records_and_rotates_at_size_cap() {
323 let dir = tempdir().unwrap();
324 let writer = LogxWriter::open(dir.path(), "inferd", 50).unwrap();
330
331 writer
332 .write_record(r#"{"msg":"first record","n":1}"#.to_string())
333 .unwrap();
334 writer
335 .write_record(r#"{"msg":"second record","n":2}"#.to_string())
336 .unwrap();
337 writer
338 .write_record(r#"{"msg":"third record","n":3}"#.to_string())
339 .unwrap();
340
341 let live = read_file(&log_path(dir.path(), "inferd", 0));
342 let one = read_file(&log_path(dir.path(), "inferd", 1));
343 let two = read_file(&log_path(dir.path(), "inferd", 2));
344
345 assert!(live.contains("third record"), "live should hold r3: {live}");
346 assert!(one.contains("second record"), ".1 should hold r2: {one}");
347 assert!(two.contains("first record"), ".2 should hold r1: {two}");
348 }
349
350 #[test]
351 fn cascade_keeps_only_three_generations() {
352 let dir = tempdir().unwrap();
353 let writer = LogxWriter::open(dir.path(), "inferd", 1024).unwrap();
354
355 writer.write_record(r#"{"g":0}"#.to_string()).unwrap();
356 for _ in 0..5 {
357 writer.rotate_now().unwrap();
358 writer.write_record(r#"{"g":"new"}"#.to_string()).unwrap();
359 }
360
361 for n in 0..=KEEP_GENERATIONS {
363 assert!(
364 log_path(dir.path(), "inferd", n).exists(),
365 "missing generation {n}"
366 );
367 }
368 assert!(
369 !log_path(dir.path(), "inferd", KEEP_GENERATIONS + 1).exists(),
370 "generation {} should have been pruned",
371 KEEP_GENERATIONS + 1
372 );
373 }
374
375 #[test]
376 fn redactor_runs_on_write_path() {
377 let dir = tempdir().unwrap();
378 let writer = LogxWriter::open(dir.path(), "inferd", 1 << 20).unwrap();
379
380 let fixture = format!("{}-{}", "sk", "1234567890abcdefghij");
383 let record = format!(r#"{{"msg":"oops","key":"{fixture}"}}"#);
384 writer.write_record(record).unwrap();
385
386 let live = read_file(&log_path(dir.path(), "inferd", 0));
387 assert!(!live.contains(&fixture), "secret leaked: {live}");
388 assert!(
389 live.contains("[REDACTED"),
390 "expected redaction marker: {live}"
391 );
392 }
393}