use std::{
fmt,
fs::{File, OpenOptions},
io::{BufWriter, Write},
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use chrono::{DateTime, Utc};
use serde_json::value::RawValue;
use tracing::{
Event, Subscriber,
field::{Field, Visit},
span::{Attributes, Id},
warn,
};
use tracing_subscriber::{layer::Context, registry::LookupSpan};
use super::{StructuredLog, format_log_timestamp};
type LogWriter = BufWriter<File>;
pub struct LogHandle {
writer: Arc<Mutex<Option<LogWriter>>>,
path: PathBuf,
}
impl LogHandle {
pub(super) fn flush(&self) {
if let Ok(mut guard) = self.writer.lock()
&& let Some(ref mut w) = *guard
&& let Err(e) = w.flush()
{
warn!("Failed to flush log file {}: {e}", self.path.display());
}
}
pub(super) fn reopen(&self) {
let new_writer = match OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
{
Ok(file) => Some(BufWriter::new(file)),
Err(e) => {
warn!(
"Failed to reopen log file {}: {e}. Suspending output to this log.",
self.path.display()
);
None
}
};
let mut guard = self.writer.lock().unwrap();
let old_writer = guard.take();
*guard = new_writer;
drop(guard);
if let Some(old_writer) = old_writer {
match old_writer.into_inner() {
Ok(old_log_file) => {
if let Err(e) = old_log_file.sync_all() {
warn!(
"Failed to sync log file {} before reopen: {e}",
self.path.display()
);
}
}
Err(e) => warn!(
"Failed to flush log file {} before reopen: {e}",
self.path.display()
),
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimestampSource {
WallClock,
Fixed,
None,
}
impl TimestampSource {
fn now(self) -> Option<DateTime<Utc>> {
match self {
Self::WallClock => Some(Utc::now()),
Self::Fixed => Some(DateTime::UNIX_EPOCH),
Self::None => None,
}
}
}
pub struct StructuredLogLayer {
writer: Arc<Mutex<Option<LogWriter>>>,
target_prefix: &'static str,
version: &'static str,
timestamp_source: TimestampSource,
}
impl StructuredLogLayer {
fn new(
log_directory: impl AsRef<Path>,
filename: &str,
target_prefix: &'static str,
version: &'static str,
timestamp_source: TimestampSource,
) -> (Self, LogHandle) {
let dir = log_directory.as_ref();
std::fs::create_dir_all(dir)
.unwrap_or_else(|e| panic!("Failed to create log directory {}: {}", dir.display(), e));
let path = dir.join(filename);
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.unwrap_or_else(|e| panic!("Failed to open log file {}: {}", path.display(), e));
let writer = Arc::new(Mutex::new(Some(BufWriter::new(file))));
let layer = Self {
writer: Arc::clone(&writer),
target_prefix,
version,
timestamp_source,
};
let handle = LogHandle { writer, path };
(layer, handle)
}
pub fn for_stream(
log_directory: impl AsRef<Path>,
stream: StructuredLog,
version: &'static str,
timestamp_source: TimestampSource,
) -> (Self, LogHandle) {
Self::new(
log_directory,
stream.file_name(),
stream.target(),
version,
timestamp_source,
)
}
}
impl<S> tracing_subscriber::Layer<S> for StructuredLogLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
if !attrs.metadata().target().starts_with(self.target_prefix) {
return;
}
let mut visitor = SeqVisitor::default();
attrs.record(&mut visitor);
if let (Some(seq), Some(span)) = (visitor.seq, ctx.span(id)) {
span.extensions_mut().replace(SpanSeq(seq));
}
}
fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) {
if !event.metadata().target().starts_with(self.target_prefix) {
return;
}
let mut visitor = EntryVisitor::default();
event.record(&mut visitor);
let Some(entry_json) = visitor.entry else {
return;
};
let Ok(members) = parse_json_members(&entry_json) else {
return;
};
let span_seq = if members.iter().any(|(key, _)| *key == "seq") {
None
} else {
ctx.event_span(event).and_then(|span| {
span.scope()
.find_map(|s| s.extensions().get::<SpanSeq>().map(|x| x.0))
})
};
let mut line = Vec::with_capacity(entry_json.len() + 128);
if write_json_line(
&mut line,
self.timestamp_source.now(),
&members,
span_seq,
self.version,
)
.is_err()
{
return;
}
if let Ok(mut guard) = self.writer.lock()
&& let Some(ref mut w) = *guard
{
let _ = w.write_all(&line);
}
}
}
fn parse_json_members(entry_json: &str) -> Result<Vec<(&str, &RawValue)>, serde_json::Error> {
use serde::de::{Deserializer, MapAccess, Visitor};
struct Members;
impl<'de> Visitor<'de> for Members {
type Value = Vec<(&'de str, &'de RawValue)>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a JSON object")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let mut members = Vec::with_capacity(map.size_hint().unwrap_or(8));
while let Some(member) = map.next_entry::<&'de str, &'de RawValue>()? {
members.push(member);
}
Ok(members)
}
}
let mut de = serde_json::Deserializer::from_str(entry_json);
let members = de.deserialize_map(Members)?;
de.end()?; Ok(members)
}
fn write_json_line<W: Write>(
w: &mut W,
timestamp: Option<DateTime<Utc>>,
members: &[(&str, &RawValue)],
span_seq: Option<u64>,
version: &str,
) -> std::io::Result<()> {
w.write_all(b"{")?;
let mut first = true;
let mut sep = |w: &mut W| -> std::io::Result<()> {
if first {
first = false;
Ok(())
} else {
w.write_all(b",")
}
};
if let Some(now) = timestamp {
sep(w)?;
write!(w, "\"timestamp\":\"{}\"", format_log_timestamp(now))?;
}
for (key, value) in members {
sep(w)?;
serde_json::to_writer(&mut *w, key)?;
w.write_all(b":")?;
w.write_all(value.get().as_bytes())?;
if *key == "event"
&& let Some(seq) = span_seq
{
write!(w, ",\"seq\":{seq}")?;
}
}
sep(w)?;
w.write_all(b"\"version\":")?;
serde_json::to_writer(&mut *w, version)?;
w.write_all(b"}\n")
}
struct SpanSeq(u64);
#[derive(Default)]
struct SeqVisitor {
seq: Option<u64>,
}
impl Visit for SeqVisitor {
fn record_u64(&mut self, field: &Field, value: u64) {
if field.name() == "seq" {
self.seq = Some(value);
}
}
fn record_debug(&mut self, _field: &Field, _value: &dyn fmt::Debug) {}
}
#[derive(Default)]
struct EntryVisitor {
entry: Option<String>,
}
impl Visit for EntryVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
if field.name() == "entry" {
self.entry = Some(value.to_string());
}
}
fn record_debug(&mut self, _field: &Field, _value: &dyn fmt::Debug) {
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Map, Value};
use std::io::Read;
use tempfile::TempDir;
use tracing_subscriber::layer::SubscriberExt;
const TEST_TARGET: &str = "test::structured";
const TEST_VERSION: &str = "1.2.3";
fn with_layer(source: TimestampSource, f: impl FnOnce()) -> String {
let tmp_dir = TempDir::new().unwrap();
let (layer, handle) = StructuredLogLayer::new(
tmp_dir.path(),
"test.log",
TEST_TARGET,
TEST_VERSION,
source,
);
let subscriber = tracing_subscriber::registry().with(layer);
tracing::subscriber::with_default(subscriber, f);
handle.flush();
let mut contents = String::new();
File::open(tmp_dir.path().join("test.log"))
.unwrap()
.read_to_string(&mut contents)
.unwrap();
contents
}
fn parse_first_line(output: &str) -> Map<String, Value> {
let line = output.lines().next().expect("expected at least one line");
serde_json::from_str(line).expect("expected valid JSON")
}
#[test]
fn writes_entry_with_correct_target() {
let output = with_layer(TimestampSource::WallClock, || {
let json = r#"{"event":"test_event","value":42}"#;
tracing::info!(target: TEST_TARGET, entry = json);
});
let map = parse_first_line(&output);
assert_eq!(map["event"], "test_event");
assert_eq!(map["value"], 42);
}
#[test]
fn ignores_events_with_wrong_target() {
let output = with_layer(TimestampSource::WallClock, || {
let json = r#"{"event":"test_event","value":42}"#;
tracing::info!(target: "wrong::target", entry = json);
});
assert!(output.is_empty());
}
#[test]
fn injects_timestamp() {
let output = with_layer(TimestampSource::WallClock, || {
let json = r#"{"event":"test_event"}"#;
tracing::info!(target: TEST_TARGET, entry = json);
});
let map = parse_first_line(&output);
let timestamp = map["timestamp"].as_str().unwrap();
assert!(timestamp.ends_with('Z'));
assert_eq!(timestamp.len(), 27);
assert_eq!(×tamp[4..5], "-");
assert_eq!(×tamp[10..11], "T");
}
#[test]
fn fixed_timestamp_source_stamps_every_line_identically() {
let output = with_layer(TimestampSource::Fixed, || {
tracing::info!(target: TEST_TARGET, entry = r#"{"event":"a"}"#);
tracing::info!(target: TEST_TARGET, entry = r#"{"event":"b"}"#);
});
let stamps: Vec<&str> = output
.lines()
.map(|l| {
let map: Map<String, Value> = serde_json::from_str(l).unwrap();
map["timestamp"].as_str().unwrap().to_owned().leak() as &str
})
.collect();
assert_eq!(stamps, ["1970-01-01T00:00:00.000000Z"; 2]);
}
#[test]
fn injects_version() {
let output = with_layer(TimestampSource::WallClock, || {
let json = r#"{"event":"test_event"}"#;
tracing::info!(target: TEST_TARGET, entry = json);
});
let map = parse_first_line(&output);
assert_eq!(map["version"], TEST_VERSION);
}
#[test]
fn field_ordering_timestamp_event_fields_version() {
let output = with_layer(TimestampSource::WallClock, || {
let json = r#"{"event":"test_event","alpha":"first","beta":"second"}"#;
tracing::info!(target: TEST_TARGET, entry = json);
});
let line = output.lines().next().unwrap();
let map: Map<String, Value> = serde_json::from_str(line).unwrap();
let keys: Vec<&str> = map.keys().map(|k| k.as_str()).collect();
assert_eq!(keys[0], "timestamp");
assert_eq!(keys[1], "event");
assert_eq!(keys.last().unwrap(), &"version");
}
#[test]
fn preserves_nested_objects() {
let output = with_layer(TimestampSource::WallClock, || {
let json = r#"{"event":"test_event","clock":{"timestamp":"2026-07-16T14:33:05.000000Z","clock_error_bound_ns":108000}}"#;
tracing::info!(target: TEST_TARGET, entry = json);
});
let map = parse_first_line(&output);
let clock = map["clock"].as_object().unwrap();
assert_eq!(clock["timestamp"], "2026-07-16T14:33:05.000000Z");
assert_eq!(clock["clock_error_bound_ns"], 108000);
}
#[test]
fn ignores_event_without_entry_field() {
let output = with_layer(TimestampSource::WallClock, || {
tracing::info!(target: TEST_TARGET, "a message without entry field");
});
assert!(output.is_empty());
}
#[test]
fn ignores_malformed_json_in_entry() {
let output = with_layer(TimestampSource::WallClock, || {
tracing::info!(target: TEST_TARGET, entry = "not valid json {{{");
});
assert!(output.is_empty());
}
#[test]
fn writes_multiple_entries() {
let output = with_layer(TimestampSource::WallClock, || {
let json1 = r#"{"event":"first"}"#;
let json2 = r#"{"event":"second"}"#;
tracing::info!(target: TEST_TARGET, entry = json1);
tracing::info!(target: TEST_TARGET, entry = json2);
});
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines.len(), 2);
let map1: Map<String, Value> = serde_json::from_str(lines[0]).unwrap();
let map2: Map<String, Value> = serde_json::from_str(lines[1]).unwrap();
assert_eq!(map1["event"], "first");
assert_eq!(map2["event"], "second");
}
#[test]
fn seq_is_stamped_from_a_span_on_the_layers_target() {
let output = with_layer(TimestampSource::Fixed, || {
let _own = tracing::trace_span!(target: TEST_TARGET, "feed", seq = 7_u64).entered();
tracing::info!(target: TEST_TARGET, entry = r#"{"event":"x","x":{}}"#);
let _other = tracing::trace_span!(target: "elsewhere", "feed", seq = 9_u64).entered();
tracing::info!(target: TEST_TARGET, entry = r#"{"event":"y","y":{}}"#);
tracing::info!(target: TEST_TARGET, entry = r#"{"event":"z","seq":3,"z":{}}"#);
});
let lines: Vec<&str> = output.lines().collect();
assert!(
lines[0].contains(r#""event":"x","seq":7,"x""#),
"{}",
lines[0]
);
assert!(
lines[1].contains(r#""event":"y","seq":7,"y""#),
"{}",
lines[1]
); assert!(
lines[2].contains(r#""event":"z","seq":3,"z""#),
"{}",
lines[2]
); }
#[test]
fn reopen_flushes_the_old_file_and_starts_the_new_one() {
let tmp_dir = TempDir::new().unwrap();
let path = tmp_dir.path().join("test.log");
let rotated = tmp_dir.path().join("test.log.1");
let (layer, handle) = StructuredLogLayer::new(
tmp_dir.path(),
"test.log",
TEST_TARGET,
TEST_VERSION,
TimestampSource::None,
);
let subscriber = tracing_subscriber::registry().with(layer);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(target: TEST_TARGET, entry = r#"{"event":"before"}"#);
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
std::fs::rename(&path, &rotated).unwrap(); handle.reopen(); tracing::info!(target: TEST_TARGET, entry = r#"{"event":"after"}"#);
});
handle.flush();
let read = |p: &Path| std::fs::read_to_string(p).unwrap();
assert_eq!(
read(&rotated).trim_end(),
r#"{"event":"before","version":"1.2.3"}"#
);
assert_eq!(
read(&path).trim_end(),
r#"{"event":"after","version":"1.2.3"}"#
);
}
#[test]
fn values_pass_through_as_written() {
let entry = r#"{"event":"x","s":"a \"quoted\" } brace","n":{"a":[1,2.5,null]},"z":-3}"#;
let output = with_layer(TimestampSource::None, || {
tracing::info!(target: TEST_TARGET, entry = entry);
});
assert_eq!(
output.trim_end(),
r#"{"event":"x","s":"a \"quoted\" } brace","n":{"a":[1,2.5,null]},"z":-3,"version":"1.2.3"}"#
);
}
#[test]
fn floats_keep_the_emitters_spelling_not_a_neighbour() {
let emitted = 3.846153617588737e-10_f64;
let spelled = serde_json::to_string(&emitted).unwrap();
assert_eq!(spelled, "3.846153617588737e-10");
let reparsed: f64 = serde_json::from_str(&spelled).unwrap();
assert_ne!(
reparsed.to_bits(),
emitted.to_bits(),
"serde_json's parse has become exact; this test and the comment in on_event can go"
);
assert_eq!(
serde_json::to_string(&reparsed).unwrap(),
"3.8461536175887366e-10"
);
let output = with_layer(TimestampSource::None, || {
tracing::info!(target: TEST_TARGET, entry = r#"{"event":"x","x":{"p":3.846153617588737e-10}}"#);
});
assert_eq!(
output.trim_end(),
r#"{"event":"x","x":{"p":3.846153617588737e-10},"version":"1.2.3"}"#
);
}
#[test]
fn no_timestamp_source_omits_the_field() {
let output = with_layer(TimestampSource::None, || {
tracing::info!(target: TEST_TARGET, entry = r#"{"event":"x","x":{}}"#);
});
assert_eq!(
output.trim_end(),
r#"{"event":"x","x":{},"version":"1.2.3"}"#
);
}
}