use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use chrono::Utc;
use serde_json::{Value, json};
use tracing::field::{Field, Visit};
use tracing::{Event, Subscriber};
use tracing_subscriber::Layer;
use tracing_subscriber::layer::Context;
use tracing_subscriber::registry::LookupSpan;
use crate::redact::redact_in_place;
pub const DEFAULT_ROTATE_BYTES: u64 = 16 * 1024 * 1024;
pub const KEEP_GENERATIONS: u32 = 3;
pub struct LogxWriter {
inner: Mutex<Inner>,
}
struct Inner {
dir: PathBuf,
base: String,
rotate_bytes: u64,
file: File,
written: u64,
}
impl LogxWriter {
pub fn open(dir: &Path, base: &str, rotate_bytes: u64) -> io::Result<Self> {
std::fs::create_dir_all(dir)?;
let path = log_path(dir, base, 0);
let file = OpenOptions::new().create(true).append(true).open(&path)?;
let written = file.metadata().map(|m| m.len()).unwrap_or(0);
Ok(Self {
inner: Mutex::new(Inner {
dir: dir.to_path_buf(),
base: base.to_string(),
rotate_bytes,
file,
written,
}),
})
}
pub fn write_record(&self, mut record: String) -> io::Result<()> {
redact_in_place(&mut record);
let mut bytes = record.into_bytes();
bytes.push(b'\n');
let mut inner = self
.inner
.lock()
.map_err(|_| io::Error::other("logx mutex poisoned"))?;
if inner.written.saturating_add(bytes.len() as u64) > inner.rotate_bytes
&& inner.written > 0
{
inner.rotate()?;
}
inner.file.write_all(&bytes)?;
inner.written = inner.written.saturating_add(bytes.len() as u64);
Ok(())
}
pub fn rotate_now(&self) -> io::Result<()> {
let mut inner = self
.inner
.lock()
.map_err(|_| io::Error::other("logx mutex poisoned"))?;
inner.rotate()
}
}
impl Inner {
fn rotate(&mut self) -> io::Result<()> {
drop_file(&mut self.file);
let dir = self.dir.clone();
let base = self.base.clone();
delete_if_exists(&log_path(&dir, &base, KEEP_GENERATIONS))?;
for n in (1..KEEP_GENERATIONS).rev() {
let from = log_path(&dir, &base, n);
let to = log_path(&dir, &base, n + 1);
if from.exists() {
std::fs::rename(&from, &to)?;
}
}
let live = log_path(&dir, &base, 0);
let to = log_path(&dir, &base, 1);
if live.exists() {
std::fs::rename(&live, &to)?;
}
self.file = OpenOptions::new().create(true).append(true).open(&live)?;
self.written = 0;
Ok(())
}
}
fn drop_file(_f: &mut File) {
}
fn delete_if_exists(path: &Path) -> io::Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
fn log_path(dir: &Path, base: &str, generation: u32) -> PathBuf {
if generation == 0 {
dir.join(format!("{base}.ndjson"))
} else {
dir.join(format!("{base}.ndjson.{generation}"))
}
}
pub fn default_log_dir() -> PathBuf {
if let Ok(p) = std::env::var("INFERD_LOG_DIR") {
return PathBuf::from(p);
}
let home = dirs_home().unwrap_or_else(|| PathBuf::from("."));
home.join(".inferd").join("logs")
}
fn dirs_home() -> Option<PathBuf> {
#[cfg(unix)]
{
std::env::var_os("HOME").map(PathBuf::from)
}
#[cfg(not(unix))]
{
std::env::var_os("USERPROFILE").map(PathBuf::from)
}
}
pub struct LogxLayer {
writer: Arc<LogxWriter>,
}
impl LogxLayer {
pub fn new(writer: Arc<LogxWriter>) -> Self {
Self { writer }
}
}
impl<S> Layer<S> for LogxLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
let metadata = event.metadata();
let mut visitor = JsonVisitor::default();
event.record(&mut visitor);
let message = visitor.fields.remove("message").map(value_to_string);
let mut record = serde_json::Map::new();
record.insert("t".into(), Value::String(Utc::now().to_rfc3339()));
record.insert(
"level".into(),
Value::String(metadata.level().to_string().to_lowercase()),
);
record.insert(
"component".into(),
Value::String(component_from_target(metadata.target())),
);
if let Some(msg) = message {
record.insert("msg".into(), Value::String(msg));
}
for (k, v) in visitor.fields {
record.insert(k, v);
}
let line = match serde_json::to_string(&Value::Object(record)) {
Ok(s) => s,
Err(e) => {
let mut buf = String::with_capacity(128);
let _ = write!(
buf,
r#"{{"t":"{}","level":"error","component":"logx","msg":"serialise: {}"}}"#,
Utc::now().to_rfc3339(),
e
);
buf
}
};
let _ = self.writer.write_record(line);
}
}
fn component_from_target(target: &str) -> String {
target
.split_once("::")
.map(|(_, rest)| rest.to_string())
.unwrap_or_else(|| target.to_string())
}
#[derive(Default)]
struct JsonVisitor {
fields: BTreeMap<String, Value>,
}
impl Visit for JsonVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
self.fields.insert(field.name().into(), json!(value));
}
fn record_bool(&mut self, field: &Field, value: bool) {
self.fields.insert(field.name().into(), json!(value));
}
fn record_i64(&mut self, field: &Field, value: i64) {
self.fields.insert(field.name().into(), json!(value));
}
fn record_u64(&mut self, field: &Field, value: u64) {
self.fields.insert(field.name().into(), json!(value));
}
fn record_f64(&mut self, field: &Field, value: f64) {
self.fields.insert(field.name().into(), json!(value));
}
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.fields
.insert(field.name().into(), json!(format!("{value:?}")));
}
fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
self.fields
.insert(field.name().into(), json!(value.to_string()));
}
}
fn value_to_string(v: Value) -> String {
match v {
Value::String(s) => s,
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn read_file(path: &Path) -> String {
std::fs::read_to_string(path).unwrap()
}
#[test]
fn appends_records_and_rotates_at_size_cap() {
let dir = tempdir().unwrap();
let writer = LogxWriter::open(dir.path(), "inferd", 50).unwrap();
writer
.write_record(r#"{"msg":"first record","n":1}"#.to_string())
.unwrap();
writer
.write_record(r#"{"msg":"second record","n":2}"#.to_string())
.unwrap();
writer
.write_record(r#"{"msg":"third record","n":3}"#.to_string())
.unwrap();
let live = read_file(&log_path(dir.path(), "inferd", 0));
let one = read_file(&log_path(dir.path(), "inferd", 1));
let two = read_file(&log_path(dir.path(), "inferd", 2));
assert!(live.contains("third record"), "live should hold r3: {live}");
assert!(one.contains("second record"), ".1 should hold r2: {one}");
assert!(two.contains("first record"), ".2 should hold r1: {two}");
}
#[test]
fn cascade_keeps_only_three_generations() {
let dir = tempdir().unwrap();
let writer = LogxWriter::open(dir.path(), "inferd", 1024).unwrap();
writer.write_record(r#"{"g":0}"#.to_string()).unwrap();
for _ in 0..5 {
writer.rotate_now().unwrap();
writer.write_record(r#"{"g":"new"}"#.to_string()).unwrap();
}
for n in 0..=KEEP_GENERATIONS {
assert!(
log_path(dir.path(), "inferd", n).exists(),
"missing generation {n}"
);
}
assert!(
!log_path(dir.path(), "inferd", KEEP_GENERATIONS + 1).exists(),
"generation {} should have been pruned",
KEEP_GENERATIONS + 1
);
}
#[test]
fn redactor_runs_on_write_path() {
let dir = tempdir().unwrap();
let writer = LogxWriter::open(dir.path(), "inferd", 1 << 20).unwrap();
let fixture = format!("{}-{}", "sk", "1234567890abcdefghij");
let record = format!(r#"{{"msg":"oops","key":"{fixture}"}}"#);
writer.write_record(record).unwrap();
let live = read_file(&log_path(dir.path(), "inferd", 0));
assert!(!live.contains(&fixture), "secret leaked: {live}");
assert!(
live.contains("[REDACTED"),
"expected redaction marker: {live}"
);
}
}