mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-11 05:30:19 +08:00
1212 lines
41 KiB
Rust
1212 lines
41 KiB
Rust
use std::cmp::Reverse;
|
|
use std::fmt;
|
|
use std::fs::{self, File, OpenOptions};
|
|
use std::io::{self, IsTerminal, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
use std::time::{Duration, SystemTime};
|
|
|
|
use chrono::{DateTime, Local, TimeZone};
|
|
use serde_json::{Map, Value};
|
|
use tracing::field::{Field, Visit};
|
|
use tracing::{Event, Subscriber};
|
|
use tracing_subscriber::fmt::format::{FormatEvent, FormatFields, Writer};
|
|
use tracing_subscriber::fmt::writer::MakeWriter;
|
|
use tracing_subscriber::fmt::FmtContext;
|
|
use tracing_subscriber::prelude::*;
|
|
use tracing_subscriber::registry::LookupSpan;
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
use crate::config::ServiceRuntimeConfig;
|
|
use crate::error::RuntimeBootstrapError;
|
|
use crate::observability::{FileLoggingConfig, LogDestination, LogRotation};
|
|
|
|
static TRACING_INIT: OnceLock<Result<(), String>> = OnceLock::new();
|
|
|
|
pub type LogReloader = Box<dyn Fn(&str) + Send + Sync>;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum LogFormat {
|
|
Pretty,
|
|
Json,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct RuntimeLogIdentity {
|
|
service: &'static str,
|
|
node_role: Option<String>,
|
|
instance_id: Option<String>,
|
|
}
|
|
|
|
impl RuntimeLogIdentity {
|
|
fn from_config(config: &ServiceRuntimeConfig) -> Self {
|
|
Self {
|
|
service: config.service_name,
|
|
node_role: config.observability.node_role.clone(),
|
|
instance_id: config.observability.instance_id.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct StartupCleanupWarning {
|
|
log_dir: PathBuf,
|
|
error: String,
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct RuntimeFieldVisitor {
|
|
fields: Vec<(String, Value)>,
|
|
}
|
|
|
|
impl RuntimeFieldVisitor {
|
|
fn is_empty(&self) -> bool {
|
|
self.fields.is_empty()
|
|
}
|
|
|
|
fn take_message(&mut self) -> Option<String> {
|
|
if let Some(pos) = self.fields.iter().position(|(k, _)| k == "message") {
|
|
let (_, value) = self.fields.remove(pos);
|
|
match value {
|
|
Value::String(s) => Some(s),
|
|
other => Some(other.to_string()),
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn into_json_object(self) -> Map<String, Value> {
|
|
self.fields.into_iter().collect()
|
|
}
|
|
|
|
fn record_value(&mut self, field: &Field, value: Value) {
|
|
if let Some((_, existing)) = self
|
|
.fields
|
|
.iter_mut()
|
|
.find(|(name, _)| name.as_str() == field.name())
|
|
{
|
|
*existing = value;
|
|
return;
|
|
}
|
|
self.fields.push((field.name().to_string(), value));
|
|
}
|
|
|
|
fn write_pretty(&self, writer: &mut Writer<'_>, ansi: bool) -> fmt::Result {
|
|
for (index, (key, value)) in self.fields.iter().enumerate() {
|
|
if index > 0 {
|
|
writer.write_char(' ')?;
|
|
}
|
|
write_colored(writer, key, ansi.then_some(ANSI_DIM))?;
|
|
writer.write_char('=')?;
|
|
write_pretty_value(writer, value)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Visit for RuntimeFieldVisitor {
|
|
fn record_bool(&mut self, field: &Field, value: bool) {
|
|
self.record_value(field, Value::Bool(value));
|
|
}
|
|
|
|
fn record_f64(&mut self, field: &Field, value: f64) {
|
|
self.record_value(field, serde_json::json!(value));
|
|
}
|
|
|
|
fn record_i64(&mut self, field: &Field, value: i64) {
|
|
self.record_value(field, Value::from(value));
|
|
}
|
|
|
|
fn record_u64(&mut self, field: &Field, value: u64) {
|
|
self.record_value(field, Value::from(value));
|
|
}
|
|
|
|
fn record_i128(&mut self, field: &Field, value: i128) {
|
|
self.record_value(field, Value::String(value.to_string()));
|
|
}
|
|
|
|
fn record_u128(&mut self, field: &Field, value: u128) {
|
|
self.record_value(field, Value::String(value.to_string()));
|
|
}
|
|
|
|
fn record_str(&mut self, field: &Field, value: &str) {
|
|
self.record_value(field, Value::String(value.to_string()));
|
|
}
|
|
|
|
fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
|
|
self.record_value(field, Value::String(value.to_string()));
|
|
}
|
|
|
|
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
|
|
self.record_value(field, Value::String(format!("{value:?}")));
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct PrettyRuntimeEventFormatter {
|
|
_identity: RuntimeLogIdentity,
|
|
ansi: bool,
|
|
}
|
|
|
|
impl PrettyRuntimeEventFormatter {
|
|
fn new(identity: RuntimeLogIdentity, ansi: bool) -> Self {
|
|
Self {
|
|
_identity: identity,
|
|
ansi,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<S, N> FormatEvent<S, N> for PrettyRuntimeEventFormatter
|
|
where
|
|
S: Subscriber + for<'lookup> LookupSpan<'lookup>,
|
|
N: for<'writer> FormatFields<'writer> + 'static,
|
|
{
|
|
fn format_event(
|
|
&self,
|
|
ctx: &FmtContext<'_, S, N>,
|
|
mut writer: Writer<'_>,
|
|
event: &Event<'_>,
|
|
) -> fmt::Result {
|
|
let meta = event.metadata();
|
|
let mut fields = RuntimeFieldVisitor::default();
|
|
event.record(&mut fields);
|
|
|
|
let level_color = self.ansi.then_some(level_ansi(meta.level()));
|
|
let message = fields.take_message();
|
|
let depth = ctx.event_scope().map(|scope| scope.count()).unwrap_or(0);
|
|
|
|
// timestamp (green)
|
|
write_colored(
|
|
&mut writer,
|
|
&formatted_timestamp(),
|
|
self.ansi.then_some(ANSI_GREEN),
|
|
)?;
|
|
// separator
|
|
write_separator(&mut writer, self.ansi)?;
|
|
// level (bold + level color, padded to 8 chars like loguru)
|
|
write_colored(&mut writer, &format!("{:<8}", meta.level()), level_color)?;
|
|
// separator
|
|
write_separator(&mut writer, self.ansi)?;
|
|
// target (cyan, shortened/truncated to fixed width)
|
|
let target_cell = format_target_cell(meta.target(), TARGET_COLUMN_WIDTH);
|
|
write_colored(&mut writer, &target_cell, self.ansi.then_some(ANSI_CYAN))?;
|
|
// message (level color, after " - ")
|
|
if let Some(ref msg) = message {
|
|
write_colored(&mut writer, " - ", self.ansi.then_some(ANSI_DIM))?;
|
|
let prefix = span_tree_prefix(depth);
|
|
if !prefix.is_empty() {
|
|
write_colored(&mut writer, &prefix, self.ansi.then_some(ANSI_DIM))?;
|
|
}
|
|
write_colored(&mut writer, msg, level_color)?;
|
|
}
|
|
// remaining structured fields
|
|
if !fields.is_empty() {
|
|
write_separator(&mut writer, self.ansi)?;
|
|
fields.write_pretty(&mut writer, self.ansi)?;
|
|
}
|
|
writeln!(writer)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct JsonRuntimeEventFormatter {
|
|
identity: RuntimeLogIdentity,
|
|
}
|
|
|
|
impl JsonRuntimeEventFormatter {
|
|
fn new(identity: RuntimeLogIdentity) -> Self {
|
|
Self { identity }
|
|
}
|
|
}
|
|
|
|
impl<S, N> FormatEvent<S, N> for JsonRuntimeEventFormatter
|
|
where
|
|
S: Subscriber + for<'lookup> LookupSpan<'lookup>,
|
|
N: for<'writer> FormatFields<'writer> + 'static,
|
|
{
|
|
fn format_event(
|
|
&self,
|
|
ctx: &FmtContext<'_, S, N>,
|
|
mut writer: Writer<'_>,
|
|
event: &Event<'_>,
|
|
) -> fmt::Result {
|
|
let meta = event.metadata();
|
|
let mut fields = RuntimeFieldVisitor::default();
|
|
event.record(&mut fields);
|
|
let depth = ctx.event_scope().map(|scope| scope.count()).unwrap_or(0);
|
|
|
|
let mut payload = Map::new();
|
|
payload.insert(
|
|
"timestamp".to_string(),
|
|
Value::String(formatted_timestamp()),
|
|
);
|
|
payload.insert("level".to_string(), Value::String(meta.level().to_string()));
|
|
payload.insert(
|
|
"service".to_string(),
|
|
Value::String(self.identity.service.to_string()),
|
|
);
|
|
payload.insert(
|
|
"node_role".to_string(),
|
|
self.identity
|
|
.node_role
|
|
.as_ref()
|
|
.map(|value| Value::String(value.clone()))
|
|
.unwrap_or(Value::Null),
|
|
);
|
|
payload.insert(
|
|
"instance_id".to_string(),
|
|
self.identity
|
|
.instance_id
|
|
.as_ref()
|
|
.map(|value| Value::String(value.clone()))
|
|
.unwrap_or(Value::Null),
|
|
);
|
|
payload.insert(
|
|
"target".to_string(),
|
|
Value::String(meta.target().to_string()),
|
|
);
|
|
payload.insert("span_depth".to_string(), Value::from(depth as u64));
|
|
payload.insert(
|
|
"fields".to_string(),
|
|
Value::Object(fields.into_json_object()),
|
|
);
|
|
|
|
writer
|
|
.write_str(&serde_json::to_string(&Value::Object(payload)).map_err(|_| fmt::Error)?)?;
|
|
writeln!(writer)
|
|
}
|
|
}
|
|
|
|
fn formatted_timestamp() -> String {
|
|
Local::now().format("%Y-%m-%d %H:%M:%S%.3f %:z").to_string()
|
|
}
|
|
|
|
const TARGET_COLUMN_WIDTH: usize = 24;
|
|
|
|
fn shorten_target(target: &str, max_segments: usize) -> &str {
|
|
let mut count = 0usize;
|
|
for (idx, _) in target.rmatch_indices("::") {
|
|
count += 1;
|
|
if count >= max_segments {
|
|
return &target[idx + 2..];
|
|
}
|
|
}
|
|
target
|
|
}
|
|
|
|
fn span_tree_prefix(depth: usize) -> String {
|
|
if depth == 0 {
|
|
return String::new();
|
|
}
|
|
|
|
let mut prefix = String::new();
|
|
for _ in 0..depth.saturating_sub(1) {
|
|
prefix.push_str("│ ");
|
|
}
|
|
prefix.push_str("├─ ");
|
|
prefix
|
|
}
|
|
|
|
fn format_target_cell(target: &str, width: usize) -> String {
|
|
let short_target = shorten_target(target, 2);
|
|
let len = short_target.chars().count();
|
|
if len <= width {
|
|
let padding = " ".repeat(width - len);
|
|
return format!("{short_target}{padding}");
|
|
}
|
|
|
|
let tail: String = short_target
|
|
.chars()
|
|
.rev()
|
|
.take(width.saturating_sub(1))
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.rev()
|
|
.collect();
|
|
format!("~{tail}")
|
|
}
|
|
|
|
const ANSI_RESET: &str = "\u{1b}[0m";
|
|
const ANSI_DIM: &str = "\u{1b}[2m";
|
|
const ANSI_BOLD: &str = "\u{1b}[1m";
|
|
const ANSI_GREEN: &str = "\u{1b}[32m";
|
|
const ANSI_CYAN: &str = "\u{1b}[36m";
|
|
const ANSI_BOLD_RED: &str = "\u{1b}[1;31m";
|
|
const ANSI_BOLD_YELLOW: &str = "\u{1b}[1;33m";
|
|
const ANSI_BOLD_BLUE: &str = "\u{1b}[1;34m";
|
|
const ANSI_BOLD_MAGENTA: &str = "\u{1b}[1;35m";
|
|
|
|
fn write_colored(writer: &mut Writer<'_>, value: &str, ansi_code: Option<&str>) -> fmt::Result {
|
|
if let Some(code) = ansi_code {
|
|
writer.write_str(code)?;
|
|
writer.write_str(value)?;
|
|
writer.write_str(ANSI_RESET)
|
|
} else {
|
|
writer.write_str(value)
|
|
}
|
|
}
|
|
|
|
fn write_separator(writer: &mut Writer<'_>, ansi: bool) -> fmt::Result {
|
|
write_colored(writer, " | ", ansi.then_some(ANSI_DIM))
|
|
}
|
|
|
|
fn level_ansi(level: &tracing::Level) -> &'static str {
|
|
match *level {
|
|
tracing::Level::ERROR => ANSI_BOLD_RED,
|
|
tracing::Level::WARN => ANSI_BOLD_YELLOW,
|
|
tracing::Level::INFO => ANSI_BOLD,
|
|
tracing::Level::DEBUG => ANSI_BOLD_BLUE,
|
|
tracing::Level::TRACE => ANSI_BOLD_MAGENTA,
|
|
}
|
|
}
|
|
|
|
fn stdout_supports_ansi() -> bool {
|
|
io::stdout().is_terminal()
|
|
}
|
|
|
|
fn write_pretty_value(writer: &mut Writer<'_>, value: &Value) -> fmt::Result {
|
|
match value {
|
|
Value::String(text) => write!(writer, "{text:?}"),
|
|
Value::Number(number) => write!(writer, "{number}"),
|
|
Value::Bool(boolean) => write!(writer, "{boolean}"),
|
|
Value::Null => writer.write_str("null"),
|
|
Value::Array(_) | Value::Object(_) => {
|
|
writer.write_str(&serde_json::to_string(value).map_err(|_| fmt::Error)?)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn init_tracing(config: ServiceRuntimeConfig) -> Result<(), RuntimeBootstrapError> {
|
|
TRACING_INIT
|
|
.get_or_init(|| {
|
|
let filter = EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| config.default_log_filter.into());
|
|
let identity = RuntimeLogIdentity::from_config(&config);
|
|
|
|
let (file_writer, startup_cleanup_warning) =
|
|
if config.observability.log_destination.needs_file_sink() {
|
|
let Some(file_logging) = config.observability.file_logging.clone() else {
|
|
return Err("file logging requires a configured log directory".to_string());
|
|
};
|
|
let (writer, startup_cleanup_warning) =
|
|
RollingFileMakeWriter::new(config.service_name, file_logging)?;
|
|
(Some(writer), startup_cleanup_warning)
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
let init_result = match (
|
|
config.observability.log_format,
|
|
config.observability.log_destination,
|
|
) {
|
|
(LogFormat::Pretty, LogDestination::Stdout) => tracing_subscriber::registry()
|
|
.with(filter)
|
|
.with(tracing_subscriber::fmt::layer().event_format(
|
|
PrettyRuntimeEventFormatter::new(identity.clone(), stdout_supports_ansi()),
|
|
))
|
|
.try_init(),
|
|
(LogFormat::Json, LogDestination::Stdout) => tracing_subscriber::registry()
|
|
.with(filter)
|
|
.with(
|
|
tracing_subscriber::fmt::layer()
|
|
.json()
|
|
.event_format(JsonRuntimeEventFormatter::new(identity.clone())),
|
|
)
|
|
.try_init(),
|
|
(LogFormat::Pretty, LogDestination::File) => tracing_subscriber::registry()
|
|
.with(filter)
|
|
.with(
|
|
tracing_subscriber::fmt::layer()
|
|
.with_ansi(false)
|
|
.event_format(PrettyRuntimeEventFormatter::new(identity.clone(), false))
|
|
.with_writer(file_writer.clone().expect("file writer should exist")),
|
|
)
|
|
.try_init(),
|
|
(LogFormat::Json, LogDestination::File) => tracing_subscriber::registry()
|
|
.with(filter)
|
|
.with(
|
|
tracing_subscriber::fmt::layer()
|
|
.json()
|
|
.event_format(JsonRuntimeEventFormatter::new(identity.clone()))
|
|
.with_writer(file_writer.clone().expect("file writer should exist")),
|
|
)
|
|
.try_init(),
|
|
(LogFormat::Pretty, LogDestination::Both) => tracing_subscriber::registry()
|
|
.with(filter)
|
|
.with(tracing_subscriber::fmt::layer().event_format(
|
|
PrettyRuntimeEventFormatter::new(identity.clone(), stdout_supports_ansi()),
|
|
))
|
|
.with(
|
|
tracing_subscriber::fmt::layer()
|
|
.with_ansi(false)
|
|
.event_format(PrettyRuntimeEventFormatter::new(identity.clone(), false))
|
|
.with_writer(file_writer.clone().expect("file writer should exist")),
|
|
)
|
|
.try_init(),
|
|
(LogFormat::Json, LogDestination::Both) => tracing_subscriber::registry()
|
|
.with(filter)
|
|
.with(
|
|
tracing_subscriber::fmt::layer()
|
|
.json()
|
|
.event_format(JsonRuntimeEventFormatter::new(identity.clone())),
|
|
)
|
|
.with(
|
|
tracing_subscriber::fmt::layer()
|
|
.json()
|
|
.event_format(JsonRuntimeEventFormatter::new(identity.clone()))
|
|
.with_writer(file_writer.clone().expect("file writer should exist")),
|
|
)
|
|
.try_init(),
|
|
}
|
|
.map_err(|err| err.to_string());
|
|
|
|
if init_result.is_ok() {
|
|
if let Some(warning) = startup_cleanup_warning.as_ref() {
|
|
emit_log_cleanup_warning("startup", warning.log_dir.as_path(), &warning.error);
|
|
}
|
|
if let Some(file_logging) = config.observability.file_logging.clone() {
|
|
spawn_log_cleanup_task(config.service_name, file_logging);
|
|
}
|
|
}
|
|
|
|
init_result
|
|
})
|
|
.clone()
|
|
.map_err(RuntimeBootstrapError::Tracing)
|
|
}
|
|
|
|
pub fn init_reloadable_tracing(
|
|
initial_filter: &str,
|
|
format: LogFormat,
|
|
) -> Result<LogReloader, RuntimeBootstrapError> {
|
|
init_reloadable_service_tracing(
|
|
initial_filter,
|
|
ServiceRuntimeConfig::new("runtime", "info").with_log_format(format),
|
|
)
|
|
}
|
|
|
|
pub fn init_reloadable_service_tracing(
|
|
initial_filter: &str,
|
|
config: ServiceRuntimeConfig,
|
|
) -> Result<LogReloader, RuntimeBootstrapError> {
|
|
use tracing_subscriber::reload;
|
|
|
|
let filter = EnvFilter::try_new(initial_filter).unwrap_or_else(|_| EnvFilter::new("info"));
|
|
let (filter_layer, reload_handle) = reload::Layer::new(filter);
|
|
let identity = RuntimeLogIdentity::from_config(&config);
|
|
|
|
let (file_writer, startup_cleanup_warning) =
|
|
if config.observability.log_destination.needs_file_sink() {
|
|
let Some(file_logging) = config.observability.file_logging.clone() else {
|
|
return Err(RuntimeBootstrapError::Tracing(
|
|
"file logging requires a configured log directory".to_string(),
|
|
));
|
|
};
|
|
let (writer, startup_cleanup_warning) =
|
|
RollingFileMakeWriter::new(config.service_name, file_logging)
|
|
.map_err(RuntimeBootstrapError::Tracing)?;
|
|
(Some(writer), startup_cleanup_warning)
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
match (
|
|
config.observability.log_format,
|
|
config.observability.log_destination,
|
|
) {
|
|
(LogFormat::Pretty, LogDestination::Stdout) => {
|
|
tracing_subscriber::registry()
|
|
.with(filter_layer)
|
|
.with(tracing_subscriber::fmt::layer().event_format(
|
|
PrettyRuntimeEventFormatter::new(identity.clone(), stdout_supports_ansi()),
|
|
))
|
|
.try_init()
|
|
}
|
|
(LogFormat::Json, LogDestination::Stdout) => tracing_subscriber::registry()
|
|
.with(filter_layer)
|
|
.with(
|
|
tracing_subscriber::fmt::layer()
|
|
.json()
|
|
.event_format(JsonRuntimeEventFormatter::new(identity.clone())),
|
|
)
|
|
.try_init(),
|
|
(LogFormat::Pretty, LogDestination::File) => tracing_subscriber::registry()
|
|
.with(filter_layer)
|
|
.with(
|
|
tracing_subscriber::fmt::layer()
|
|
.with_ansi(false)
|
|
.event_format(PrettyRuntimeEventFormatter::new(identity.clone(), false))
|
|
.with_writer(file_writer.clone().expect("file writer should exist")),
|
|
)
|
|
.try_init(),
|
|
(LogFormat::Json, LogDestination::File) => tracing_subscriber::registry()
|
|
.with(filter_layer)
|
|
.with(
|
|
tracing_subscriber::fmt::layer()
|
|
.json()
|
|
.event_format(JsonRuntimeEventFormatter::new(identity.clone()))
|
|
.with_writer(file_writer.clone().expect("file writer should exist")),
|
|
)
|
|
.try_init(),
|
|
(LogFormat::Pretty, LogDestination::Both) => {
|
|
tracing_subscriber::registry()
|
|
.with(filter_layer)
|
|
.with(tracing_subscriber::fmt::layer().event_format(
|
|
PrettyRuntimeEventFormatter::new(identity.clone(), stdout_supports_ansi()),
|
|
))
|
|
.with(
|
|
tracing_subscriber::fmt::layer()
|
|
.with_ansi(false)
|
|
.event_format(PrettyRuntimeEventFormatter::new(identity.clone(), false))
|
|
.with_writer(file_writer.clone().expect("file writer should exist")),
|
|
)
|
|
.try_init()
|
|
}
|
|
(LogFormat::Json, LogDestination::Both) => tracing_subscriber::registry()
|
|
.with(filter_layer)
|
|
.with(
|
|
tracing_subscriber::fmt::layer()
|
|
.json()
|
|
.event_format(JsonRuntimeEventFormatter::new(identity.clone())),
|
|
)
|
|
.with(
|
|
tracing_subscriber::fmt::layer()
|
|
.json()
|
|
.event_format(JsonRuntimeEventFormatter::new(identity.clone()))
|
|
.with_writer(file_writer.clone().expect("file writer should exist")),
|
|
)
|
|
.try_init(),
|
|
}
|
|
.map_err(|err| RuntimeBootstrapError::Tracing(err.to_string()))?;
|
|
|
|
if let Some(warning) = startup_cleanup_warning.as_ref() {
|
|
emit_log_cleanup_warning("startup", warning.log_dir.as_path(), &warning.error);
|
|
}
|
|
if let Some(file_logging) = config.observability.file_logging.clone() {
|
|
spawn_log_cleanup_task(config.service_name, file_logging);
|
|
}
|
|
|
|
Ok(Box::new(move |level: &str| {
|
|
if let Ok(new_filter) = EnvFilter::try_new(level) {
|
|
let _ = reload_handle.modify(|filter| *filter = new_filter);
|
|
}
|
|
}))
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct RollingFileMakeWriter {
|
|
sink: Arc<RollingFileSink>,
|
|
}
|
|
|
|
impl RollingFileMakeWriter {
|
|
fn new(
|
|
service_name: &'static str,
|
|
config: FileLoggingConfig,
|
|
) -> Result<(Self, Option<StartupCleanupWarning>), String> {
|
|
let (sink, startup_cleanup_warning) =
|
|
RollingFileSink::new(service_name, config).map_err(|err| err.to_string())?;
|
|
Ok((
|
|
Self {
|
|
sink: Arc::new(sink),
|
|
},
|
|
startup_cleanup_warning,
|
|
))
|
|
}
|
|
}
|
|
|
|
impl<'a> MakeWriter<'a> for RollingFileMakeWriter {
|
|
type Writer = RollingFileWriter;
|
|
|
|
fn make_writer(&'a self) -> Self::Writer {
|
|
RollingFileWriter {
|
|
sink: Arc::clone(&self.sink),
|
|
}
|
|
}
|
|
}
|
|
|
|
struct RollingFileWriter {
|
|
sink: Arc<RollingFileSink>,
|
|
}
|
|
|
|
impl Write for RollingFileWriter {
|
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
|
self.sink.write(buf)
|
|
}
|
|
|
|
fn flush(&mut self) -> io::Result<()> {
|
|
self.sink.flush()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct RollingFileSink {
|
|
service_name: &'static str,
|
|
config: FileLoggingConfig,
|
|
state: Mutex<RollingFileState>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct RollingFileState {
|
|
current_bucket: String,
|
|
file: File,
|
|
}
|
|
|
|
impl RollingFileSink {
|
|
fn new(
|
|
service_name: &'static str,
|
|
config: FileLoggingConfig,
|
|
) -> io::Result<(Self, Option<StartupCleanupWarning>)> {
|
|
Self::new_with_cleanup(service_name, config, cleanup_log_files)
|
|
}
|
|
|
|
fn new_with_cleanup(
|
|
service_name: &'static str,
|
|
config: FileLoggingConfig,
|
|
cleanup: fn(&str, &FileLoggingConfig) -> io::Result<usize>,
|
|
) -> io::Result<(Self, Option<StartupCleanupWarning>)> {
|
|
fs::create_dir_all(&config.dir)?;
|
|
let startup_cleanup_warning =
|
|
cleanup(service_name, &config)
|
|
.err()
|
|
.map(|err| StartupCleanupWarning {
|
|
log_dir: config.dir.clone(),
|
|
error: err.to_string(),
|
|
});
|
|
let now = Local::now();
|
|
let current_bucket = log_bucket_key(config.rotation, now);
|
|
let file = open_bucketed_log_file(&config.dir, service_name, ¤t_bucket)?;
|
|
Ok((
|
|
Self {
|
|
service_name,
|
|
config,
|
|
state: Mutex::new(RollingFileState {
|
|
current_bucket,
|
|
file,
|
|
}),
|
|
},
|
|
startup_cleanup_warning,
|
|
))
|
|
}
|
|
|
|
fn write(&self, buf: &[u8]) -> io::Result<usize> {
|
|
let now = Local::now();
|
|
let mut state = self
|
|
.state
|
|
.lock()
|
|
.map_err(|_| io::Error::other("log writer mutex poisoned"))?;
|
|
let target_bucket = log_bucket_key(self.config.rotation, now);
|
|
if state.current_bucket != target_bucket {
|
|
state.file.flush()?;
|
|
state.file =
|
|
open_bucketed_log_file(&self.config.dir, self.service_name, &target_bucket)?;
|
|
state.current_bucket = target_bucket;
|
|
}
|
|
state.file.write(buf)
|
|
}
|
|
|
|
fn flush(&self) -> io::Result<()> {
|
|
let mut state = self
|
|
.state
|
|
.lock()
|
|
.map_err(|_| io::Error::other("log writer mutex poisoned"))?;
|
|
state.file.flush()
|
|
}
|
|
}
|
|
|
|
fn open_bucketed_log_file(dir: &Path, service_name: &str, bucket: &str) -> io::Result<File> {
|
|
OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(bucketed_log_path(dir, service_name, bucket))
|
|
}
|
|
|
|
fn bucketed_log_path(dir: &Path, service_name: &str, bucket: &str) -> PathBuf {
|
|
dir.join(format!("{service_name}.{bucket}.log"))
|
|
}
|
|
|
|
fn log_bucket_key<Tz>(rotation: LogRotation, now: DateTime<Tz>) -> String
|
|
where
|
|
Tz: TimeZone,
|
|
Tz::Offset: std::fmt::Display,
|
|
{
|
|
match rotation {
|
|
LogRotation::Hourly => now.format("%Y-%m-%d-%H").to_string(),
|
|
LogRotation::Daily => now.format("%Y-%m-%d").to_string(),
|
|
}
|
|
}
|
|
|
|
fn spawn_log_cleanup_task(service_name: &'static str, config: FileLoggingConfig) {
|
|
if tokio::runtime::Handle::try_current().is_err() {
|
|
return;
|
|
}
|
|
|
|
tokio::spawn(async move {
|
|
let interval = Duration::from_secs(6 * 60 * 60);
|
|
loop {
|
|
tokio::time::sleep(interval).await;
|
|
if let Err(err) = cleanup_log_files(service_name, &config) {
|
|
emit_log_cleanup_warning("background", config.dir.as_path(), &err);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn emit_log_cleanup_warning(phase: &'static str, log_dir: &Path, error: &impl std::fmt::Display) {
|
|
tracing::warn!(
|
|
event_name = "log_retention_cleanup_failed",
|
|
log_type = "ops",
|
|
phase,
|
|
log_dir = %log_dir.display(),
|
|
error = %error,
|
|
"log retention cleanup failed"
|
|
);
|
|
}
|
|
|
|
fn cleanup_log_files(service_name: &str, config: &FileLoggingConfig) -> io::Result<usize> {
|
|
let candidates = collect_log_file_candidates(service_name, &config.dir)?;
|
|
let now = SystemTime::now();
|
|
let targets =
|
|
select_log_files_for_cleanup(candidates, now, config.retention_days, config.max_files);
|
|
let mut removed = 0usize;
|
|
for path in targets {
|
|
match fs::remove_file(&path) {
|
|
Ok(()) => removed += 1,
|
|
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
|
|
Err(err) => return Err(err),
|
|
}
|
|
}
|
|
Ok(removed)
|
|
}
|
|
|
|
fn collect_log_file_candidates(
|
|
service_name: &str,
|
|
dir: &Path,
|
|
) -> io::Result<Vec<LogFileCandidate>> {
|
|
let prefix = format!("{service_name}.");
|
|
let mut candidates = Vec::new();
|
|
for entry in fs::read_dir(dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
if !path.is_file() {
|
|
continue;
|
|
}
|
|
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
|
|
continue;
|
|
};
|
|
if !name.starts_with(&prefix) || !name.ends_with(".log") {
|
|
continue;
|
|
}
|
|
let modified_at = entry
|
|
.metadata()?
|
|
.modified()
|
|
.unwrap_or(SystemTime::UNIX_EPOCH);
|
|
candidates.push(LogFileCandidate { path, modified_at });
|
|
}
|
|
Ok(candidates)
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct LogFileCandidate {
|
|
path: PathBuf,
|
|
modified_at: SystemTime,
|
|
}
|
|
|
|
fn select_log_files_for_cleanup(
|
|
mut candidates: Vec<LogFileCandidate>,
|
|
now: SystemTime,
|
|
retention_days: u64,
|
|
max_files: usize,
|
|
) -> Vec<PathBuf> {
|
|
candidates.sort_by_key(|candidate| Reverse(candidate.modified_at));
|
|
let age_cutoff = retention_days
|
|
.checked_mul(86_400)
|
|
.and_then(|seconds| now.checked_sub(Duration::from_secs(seconds)));
|
|
candidates
|
|
.into_iter()
|
|
.enumerate()
|
|
.filter_map(|(index, candidate)| {
|
|
let exceeds_count = max_files > 0 && index >= max_files;
|
|
let exceeds_age = age_cutoff.is_some_and(|cutoff| candidate.modified_at < cutoff);
|
|
(exceeds_count || exceeds_age).then_some(candidate.path)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
bucketed_log_path, cleanup_log_files, format_target_cell, log_bucket_key,
|
|
select_log_files_for_cleanup, FileLoggingConfig, JsonRuntimeEventFormatter,
|
|
LogFileCandidate, LogRotation, PrettyRuntimeEventFormatter, RollingFileSink,
|
|
RuntimeLogIdentity,
|
|
};
|
|
use chrono::{Local, TimeZone};
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, SystemTime};
|
|
use tracing_subscriber::prelude::*;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Clone, Default)]
|
|
struct SharedBuffer(Arc<Mutex<Vec<u8>>>);
|
|
|
|
struct SharedBufferWriter(Arc<Mutex<Vec<u8>>>);
|
|
|
|
impl SharedBuffer {
|
|
fn contents(&self) -> String {
|
|
String::from_utf8(self.0.lock().expect("buffer should lock").clone())
|
|
.expect("buffer should contain valid utf-8")
|
|
}
|
|
}
|
|
|
|
impl std::io::Write for SharedBufferWriter {
|
|
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
|
self.0
|
|
.lock()
|
|
.expect("buffer should lock")
|
|
.extend_from_slice(buf);
|
|
Ok(buf.len())
|
|
}
|
|
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl<'a> tracing_subscriber::fmt::writer::MakeWriter<'a> for SharedBuffer {
|
|
type Writer = SharedBufferWriter;
|
|
|
|
fn make_writer(&'a self) -> Self::Writer {
|
|
SharedBufferWriter(Arc::clone(&self.0))
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cleanup_selection_respects_max_files_before_age() {
|
|
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000);
|
|
let candidates = vec![
|
|
LogFileCandidate {
|
|
path: PathBuf::from("newest.log"),
|
|
modified_at: now,
|
|
},
|
|
LogFileCandidate {
|
|
path: PathBuf::from("middle.log"),
|
|
modified_at: now - Duration::from_secs(60),
|
|
},
|
|
LogFileCandidate {
|
|
path: PathBuf::from("oldest.log"),
|
|
modified_at: now - Duration::from_secs(120),
|
|
},
|
|
];
|
|
|
|
let removed = select_log_files_for_cleanup(candidates, now, 30, 2);
|
|
assert_eq!(removed, vec![PathBuf::from("oldest.log")]);
|
|
}
|
|
|
|
#[test]
|
|
fn cleanup_selection_respects_retention_days() {
|
|
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(20 * 86_400);
|
|
let candidates = vec![
|
|
LogFileCandidate {
|
|
path: PathBuf::from("fresh.log"),
|
|
modified_at: now - Duration::from_secs(2 * 86_400),
|
|
},
|
|
LogFileCandidate {
|
|
path: PathBuf::from("expired.log"),
|
|
modified_at: now - Duration::from_secs(10 * 86_400),
|
|
},
|
|
];
|
|
|
|
let removed = select_log_files_for_cleanup(candidates, now, 7, 0);
|
|
assert_eq!(removed, vec![PathBuf::from("expired.log")]);
|
|
}
|
|
|
|
#[test]
|
|
fn log_bucket_key_formats_daily_and_hourly_rotations() {
|
|
let instant = Local
|
|
.with_ymd_and_hms(2026, 4, 4, 13, 37, 0)
|
|
.single()
|
|
.expect("timestamp should build");
|
|
|
|
assert_eq!(
|
|
log_bucket_key(LogRotation::Daily, instant),
|
|
"2026-04-04".to_string()
|
|
);
|
|
assert_eq!(
|
|
log_bucket_key(LogRotation::Hourly, instant),
|
|
"2026-04-04-13".to_string()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cleanup_log_files_removes_matching_files_on_disk() {
|
|
let dir = std::env::temp_dir().join(format!("aether-runtime-logs-{}", Uuid::new_v4()));
|
|
fs::create_dir_all(&dir).expect("temp dir should exist");
|
|
|
|
let file_a = bucketed_log_path(&dir, "runtime-test", "2026-04-03");
|
|
let file_b = bucketed_log_path(&dir, "runtime-test", "2026-04-04");
|
|
fs::write(&file_a, b"one").expect("file a should be created");
|
|
fs::write(&file_b, b"two").expect("file b should be created");
|
|
|
|
let config = FileLoggingConfig::new(&dir, LogRotation::Daily, 0, 0);
|
|
let removed =
|
|
cleanup_log_files("runtime-test", &config).expect("cleanup should succeed on disk");
|
|
|
|
assert_eq!(removed, 2);
|
|
assert!(!file_a.exists(), "cleanup should remove file a");
|
|
assert!(!file_b.exists(), "cleanup should remove file b");
|
|
|
|
fs::remove_dir_all(&dir).expect("temp dir should be removable");
|
|
}
|
|
|
|
#[test]
|
|
fn rolling_file_sink_treats_startup_cleanup_failure_as_non_fatal() {
|
|
fn fail_cleanup(_: &str, _: &FileLoggingConfig) -> std::io::Result<usize> {
|
|
Err(std::io::Error::new(
|
|
std::io::ErrorKind::PermissionDenied,
|
|
"cleanup denied",
|
|
))
|
|
}
|
|
|
|
let dir = std::env::temp_dir().join(format!("aether-runtime-logs-{}", Uuid::new_v4()));
|
|
let config = FileLoggingConfig::new(&dir, LogRotation::Daily, 7, 30);
|
|
|
|
let (sink, warning) =
|
|
RollingFileSink::new_with_cleanup("runtime-test", config, fail_cleanup)
|
|
.expect("startup cleanup failure should not block sink creation");
|
|
|
|
assert_eq!(sink.service_name, "runtime-test");
|
|
let warning = warning.expect("startup cleanup warning should be surfaced");
|
|
assert_eq!(warning.log_dir, dir);
|
|
assert_eq!(warning.error, "cleanup denied".to_string());
|
|
|
|
fs::remove_dir_all(&warning.log_dir).expect("temp dir should be removable");
|
|
}
|
|
|
|
#[test]
|
|
fn pretty_formatter_omits_service_identity_fields() {
|
|
let writer = SharedBuffer::default();
|
|
let subscriber = tracing_subscriber::registry().with(
|
|
tracing_subscriber::fmt::layer()
|
|
.with_writer(writer.clone())
|
|
.event_format(PrettyRuntimeEventFormatter::new(
|
|
RuntimeLogIdentity {
|
|
service: "test-service",
|
|
node_role: Some("frontdoor".to_string()),
|
|
instance_id: Some("gateway-a".to_string()),
|
|
},
|
|
false,
|
|
)),
|
|
);
|
|
let dispatch = tracing::Dispatch::new(subscriber);
|
|
let _guard = tracing::dispatcher::set_default(&dispatch);
|
|
|
|
tracing::info!(
|
|
target: "runtime::tracing",
|
|
event_name = "test_event",
|
|
value = 7_u64,
|
|
"hello"
|
|
);
|
|
|
|
let output = writer.contents();
|
|
assert!(
|
|
!output.contains("test-service:frontdoor"),
|
|
"should not contain compact identity"
|
|
);
|
|
assert!(
|
|
output.contains(" | INFO"),
|
|
"should contain pipe-separated level"
|
|
);
|
|
assert!(
|
|
output.contains("runtime::tracing"),
|
|
"should contain shortened target"
|
|
);
|
|
assert!(
|
|
output.contains(" - hello"),
|
|
"should contain message after dash"
|
|
);
|
|
assert!(output.contains("event_name=\"test_event\""));
|
|
assert!(output.contains("value=7"));
|
|
}
|
|
|
|
#[test]
|
|
fn pretty_formatter_emits_ansi_when_enabled() {
|
|
let writer = SharedBuffer::default();
|
|
let subscriber = tracing_subscriber::registry().with(
|
|
tracing_subscriber::fmt::layer()
|
|
.with_writer(writer.clone())
|
|
.event_format(PrettyRuntimeEventFormatter::new(
|
|
RuntimeLogIdentity {
|
|
service: "test-service",
|
|
node_role: Some("frontdoor".to_string()),
|
|
instance_id: Some("gateway-a".to_string()),
|
|
},
|
|
true,
|
|
)),
|
|
);
|
|
let dispatch = tracing::Dispatch::new(subscriber);
|
|
let _guard = tracing::dispatcher::set_default(&dispatch);
|
|
|
|
tracing::warn!(event_name = "ansi_event", "colored");
|
|
|
|
let output = writer.contents();
|
|
assert!(
|
|
output.contains("\u{1b}["),
|
|
"should contain ANSI escape sequences"
|
|
);
|
|
assert!(
|
|
!output.contains("test-service:frontdoor"),
|
|
"should not contain compact identity"
|
|
);
|
|
assert!(output.contains("colored"), "should contain message text");
|
|
assert!(output.contains("ansi_event"));
|
|
}
|
|
|
|
#[test]
|
|
fn pretty_formatter_adds_tree_prefix_inside_span() {
|
|
let writer = SharedBuffer::default();
|
|
let subscriber = tracing_subscriber::registry().with(
|
|
tracing_subscriber::fmt::layer()
|
|
.with_writer(writer.clone())
|
|
.event_format(PrettyRuntimeEventFormatter::new(
|
|
RuntimeLogIdentity {
|
|
service: "test-service",
|
|
node_role: Some("frontdoor".to_string()),
|
|
instance_id: Some("gateway-a".to_string()),
|
|
},
|
|
false,
|
|
)),
|
|
);
|
|
let dispatch = tracing::Dispatch::new(subscriber);
|
|
let _guard = tracing::dispatcher::set_default(&dispatch);
|
|
|
|
tracing::info_span!("candidates").in_scope(|| {
|
|
tracing::debug!(
|
|
target: "executor::candidate_loop",
|
|
event_name = "candidate_loop_started",
|
|
"inside span"
|
|
);
|
|
});
|
|
|
|
let output = writer.contents();
|
|
assert!(output.contains("executor::candidate_loop"));
|
|
assert!(output.contains(" - ├─ inside span"));
|
|
}
|
|
|
|
#[test]
|
|
fn pretty_formatter_keeps_target_column_aligned() {
|
|
let writer = SharedBuffer::default();
|
|
let subscriber = tracing_subscriber::registry().with(
|
|
tracing_subscriber::fmt::layer()
|
|
.with_writer(writer.clone())
|
|
.event_format(PrettyRuntimeEventFormatter::new(
|
|
RuntimeLogIdentity {
|
|
service: "test-service",
|
|
node_role: Some("frontdoor".to_string()),
|
|
instance_id: Some("gateway-a".to_string()),
|
|
},
|
|
false,
|
|
)),
|
|
);
|
|
let dispatch = tracing::Dispatch::new(subscriber);
|
|
let _guard = tracing::dispatcher::set_default(&dispatch);
|
|
|
|
tracing::info!(
|
|
target: "short::name",
|
|
event_name = "short_event",
|
|
"short message"
|
|
);
|
|
tracing::info!(
|
|
target: "root::supercalifragilistic::anotherverylongsegment",
|
|
event_name = "long_event",
|
|
"long message"
|
|
);
|
|
|
|
let output = writer.contents();
|
|
let lines = output.lines().collect::<Vec<_>>();
|
|
assert_eq!(lines.len(), 2, "expected exactly two log lines");
|
|
let first_dash = lines[0]
|
|
.find(" - ")
|
|
.expect("short line should contain message separator");
|
|
let second_dash = lines[1]
|
|
.find(" - ")
|
|
.expect("long line should contain message separator");
|
|
assert_eq!(
|
|
first_dash, second_dash,
|
|
"message separator should stay aligned"
|
|
);
|
|
assert!(
|
|
lines[1].contains(&format_target_cell(
|
|
"root::supercalifragilistic::anotherverylongsegment",
|
|
super::TARGET_COLUMN_WIDTH,
|
|
)),
|
|
"long target should be truncated into the fixed-width cell"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn json_formatter_includes_service_identity_fields() {
|
|
let writer = SharedBuffer::default();
|
|
let subscriber = tracing_subscriber::registry().with(
|
|
tracing_subscriber::fmt::layer()
|
|
.json()
|
|
.with_writer(writer.clone())
|
|
.event_format(JsonRuntimeEventFormatter::new(RuntimeLogIdentity {
|
|
service: "test-service",
|
|
node_role: Some("proxy".to_string()),
|
|
instance_id: Some("proxy-01".to_string()),
|
|
})),
|
|
);
|
|
let dispatch = tracing::Dispatch::new(subscriber);
|
|
let _guard = tracing::dispatcher::set_default(&dispatch);
|
|
|
|
tracing::warn!(
|
|
event_name = "test_event",
|
|
status = "failed",
|
|
status_code = 502_u16,
|
|
"boom"
|
|
);
|
|
|
|
let output = writer.contents();
|
|
let payload: serde_json::Value =
|
|
serde_json::from_str(output.trim()).expect("json log line should parse");
|
|
assert_eq!(payload["service"], "test-service");
|
|
assert_eq!(payload["node_role"], "proxy");
|
|
assert_eq!(payload["instance_id"], "proxy-01");
|
|
assert_eq!(payload["span_depth"], 0);
|
|
assert_eq!(payload["fields"]["event_name"], "test_event");
|
|
assert_eq!(payload["fields"]["status"], "failed");
|
|
assert_eq!(payload["fields"]["status_code"], 502);
|
|
}
|
|
|
|
#[test]
|
|
fn json_formatter_includes_span_depth() {
|
|
let writer = SharedBuffer::default();
|
|
let subscriber = tracing_subscriber::registry().with(
|
|
tracing_subscriber::fmt::layer()
|
|
.json()
|
|
.with_writer(writer.clone())
|
|
.event_format(JsonRuntimeEventFormatter::new(RuntimeLogIdentity {
|
|
service: "test-service",
|
|
node_role: Some("proxy".to_string()),
|
|
instance_id: Some("proxy-01".to_string()),
|
|
})),
|
|
);
|
|
let dispatch = tracing::Dispatch::new(subscriber);
|
|
let _guard = tracing::dispatcher::set_default(&dispatch);
|
|
|
|
tracing::info_span!("request").in_scope(|| {
|
|
tracing::info!(event_name = "nested_event", "inside request span");
|
|
});
|
|
|
|
let output = writer.contents();
|
|
let payload: serde_json::Value =
|
|
serde_json::from_str(output.trim()).expect("json log line should parse");
|
|
assert_eq!(payload["span_depth"], 1);
|
|
assert_eq!(payload["fields"]["event_name"], "nested_event");
|
|
}
|
|
}
|