use serde_json::{Map, Value};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing::Event;
use tracing_subscriber::filter::EnvFilter;
use tracing_subscriber::layer::Context as LayerContext;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::util::SubscriberInitExt;
use crate::error::Result;
const LOG_FILE_SUFFIX: &str = ".jsonl";
const DEFAULT_LOG_DIR_UNIX: &str = "/var/log";
#[derive(Clone, Debug)]
pub struct LoggingConfig {
pub service: String,
pub env_log_path: String,
pub env_log_dir: String,
pub log_dir: Option<PathBuf>,
}
impl LoggingConfig {
pub fn from_app_name(app_name: &str) -> Self {
let prefix = app_name.to_uppercase().replace('-', "_");
Self {
service: app_name.to_string(),
env_log_path: format!("{prefix}_LOG_PATH"),
env_log_dir: format!("{prefix}_LOG_DIR"),
log_dir: None,
}
}
pub fn with_log_dir(mut self, dir: Option<PathBuf>) -> Self {
self.log_dir = dir;
self
}
}
pub struct LoggingGuard {
_log_guard: tracing_appender::non_blocking::WorkerGuard,
}
impl LoggingGuard {
pub(crate) const fn from_guard(guard: tracing_appender::non_blocking::WorkerGuard) -> Self {
Self { _log_guard: guard }
}
}
pub fn init(cfg: &LoggingConfig, env_filter: EnvFilter) -> Result<LoggingGuard> {
let (log_layer, log_guard) = build_json_layer(cfg)?;
tracing_subscriber::registry()
.with(env_filter)
.with(log_layer)
.try_init()
.map_err(crate::Error::TracingInit)?;
tracing::debug!("logging initialized");
Ok(LoggingGuard {
_log_guard: log_guard,
})
}
pub(crate) fn build_json_layer(
cfg: &LoggingConfig,
) -> Result<(
JsonLogLayer<tracing_appender::non_blocking::NonBlocking>,
tracing_appender::non_blocking::WorkerGuard,
)> {
let (log_writer, log_guard) = match build_log_writer(
&cfg.service,
&cfg.env_log_path,
&cfg.env_log_dir,
cfg.log_dir.as_deref(),
) {
Ok(result) => result,
Err(err) => {
eprintln!("Warning: {err}. Falling back to stderr logging.");
tracing_appender::non_blocking(std::io::stderr())
}
};
let log_layer = JsonLogLayer::new(log_writer);
Ok((log_layer, log_guard))
}
pub fn env_filter(quiet: bool, verbose: u8, default_level: &str) -> EnvFilter {
if quiet {
return EnvFilter::new("error");
}
if verbose > 0 {
let level = match verbose {
1 => "debug",
_ => "trace",
};
return EnvFilter::new(level);
}
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level))
}
#[derive(Clone, Debug)]
pub struct LogTarget {
pub dir: PathBuf,
pub file_name: String,
}
pub fn resolve_log_target_with(
service: &str,
path_override: Option<PathBuf>,
dir_override: Option<PathBuf>,
config_dir: Option<PathBuf>,
) -> std::result::Result<LogTarget, String> {
if let Some(path) = path_override {
return log_target_from_path(path);
}
if let Some(dir) = dir_override {
return log_target_from_dir(dir, service);
}
if let Some(dir) = config_dir {
return log_target_from_dir(dir, service);
}
let mut candidates = Vec::new();
if let Some(log_dir) = platform_log_dir(service) {
candidates.push(log_dir);
}
if cfg!(unix) {
candidates.push(PathBuf::from(DEFAULT_LOG_DIR_UNIX));
}
if let Ok(dir) = std::env::current_dir() {
candidates.push(dir);
}
let file_name = format!("{service}{LOG_FILE_SUFFIX}");
for dir in candidates {
if ensure_writable(&dir, &file_name).is_ok() {
return Ok(LogTarget { dir, file_name });
}
}
Err("No writable log directory found".to_string())
}
pub fn platform_log_dir(service: &str) -> Option<PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|home| PathBuf::from(home).join("Library/Logs").join(service))
} else if cfg!(unix) {
let state_base = std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state"))
})?;
Some(state_base.join(service).join("logs"))
} else {
directories::ProjectDirs::from("", "", service).map(|p| p.data_local_dir().join("logs"))
}
}
pub fn format_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let secs = now.as_secs();
let nanos = now.subsec_nanos();
let days_since_epoch = secs / 86400;
let secs_of_day = secs % 86400;
let hours = secs_of_day / 3600;
let minutes = (secs_of_day % 3600) / 60;
let seconds = secs_of_day % 60;
let (year, month, day) = days_to_ymd(days_since_epoch as i64);
format!(
"{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{millis:03}Z",
millis = nanos / 1_000_000
)
}
const fn days_to_ymd(days: i64) -> (i32, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u32;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y as i32, m, d)
}
fn build_log_writer(
service: &str,
env_log_path: &str,
env_log_dir: &str,
config_log_dir: Option<&Path>,
) -> std::result::Result<
(
tracing_appender::non_blocking::NonBlocking,
tracing_appender::non_blocking::WorkerGuard,
),
String,
> {
let path_override = std::env::var_os(env_log_path).map(PathBuf::from);
let dir_override = std::env::var_os(env_log_dir).map(PathBuf::from);
let target = resolve_log_target_with(
service,
path_override,
dir_override,
config_log_dir.map(PathBuf::from),
)?;
let appender = tracing_appender::rolling::daily(&target.dir, &target.file_name);
let (writer, guard) = tracing_appender::non_blocking(appender);
Ok((writer, guard))
}
fn log_target_from_dir(dir: PathBuf, service: &str) -> std::result::Result<LogTarget, String> {
let file_name = format!("{service}{LOG_FILE_SUFFIX}");
ensure_writable(&dir, &file_name)?;
Ok(LogTarget { dir, file_name })
}
fn log_target_from_path(path: PathBuf) -> std::result::Result<LogTarget, String> {
let file_name = path
.file_name()
.ok_or_else(|| "log path must include a file name".to_string())
.and_then(|name| {
name.to_str()
.map(|v| v.to_string())
.ok_or_else(|| "log path must be valid UTF-8".to_string())
})?;
let dir = path.parent().unwrap_or_else(|| Path::new("."));
ensure_writable(dir, &file_name)?;
Ok(LogTarget {
dir: dir.to_path_buf(),
file_name,
})
}
fn ensure_writable(dir: &Path, file_name: &str) -> std::result::Result<(), String> {
std::fs::create_dir_all(dir)
.map_err(|e| format!("Failed to create log directory {}: {e}", dir.display()))?;
let path = dir.join(file_name);
OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("Failed to open log file {}: {e}", path.display()))?;
Ok(())
}
pub(crate) struct JsonLogLayer<W> {
writer: W,
}
impl<W> JsonLogLayer<W> {
const fn new(writer: W) -> Self {
Self { writer }
}
}
impl<S, W> tracing_subscriber::Layer<S> for JsonLogLayer<W>
where
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
W: for<'writer> tracing_subscriber::fmt::MakeWriter<'writer> + Send + Sync + 'static,
{
fn on_new_span(
&self,
attrs: &tracing::span::Attributes<'_>,
id: &tracing::span::Id,
ctx: LayerContext<'_, S>,
) {
if let Some(span) = ctx.span(id) {
let mut visitor = JsonVisitor::default();
attrs.record(&mut visitor);
span.extensions_mut().insert(SpanFields {
values: visitor.values,
});
}
}
fn on_record(
&self,
id: &tracing::span::Id,
values: &tracing::span::Record<'_>,
ctx: LayerContext<'_, S>,
) {
if let Some(span) = ctx.span(id) {
let mut visitor = JsonVisitor::default();
values.record(&mut visitor);
let mut extensions = span.extensions_mut();
if let Some(fields) = extensions.get_mut::<SpanFields>() {
fields.values.extend(visitor.values);
} else {
extensions.insert(SpanFields {
values: visitor.values,
});
}
}
}
fn on_event(&self, event: &Event<'_>, ctx: LayerContext<'_, S>) {
let mut map = Map::new();
let timestamp = format_timestamp();
map.insert("timestamp".to_string(), Value::String(timestamp));
let level = match *event.metadata().level() {
tracing::Level::TRACE => "trace",
tracing::Level::DEBUG => "debug",
tracing::Level::INFO => "info",
tracing::Level::WARN => "warn",
tracing::Level::ERROR => "error",
};
map.insert("level".to_string(), Value::String(level.to_owned()));
map.insert(
"target".to_string(),
Value::String(event.metadata().target().to_string()),
);
if let Some(scope) = ctx.event_scope(event) {
for span in scope.from_root() {
if let Some(fields) = span.extensions().get::<SpanFields>() {
map.extend(fields.values.clone());
}
}
}
let mut visitor = JsonVisitor::default();
event.record(&mut visitor);
map.extend(visitor.values);
if let Ok(mut buf) = serde_json::to_vec(&Value::Object(map)) {
buf.push(b'\n');
let mut writer = self.writer.make_writer();
if writer.write_all(&buf).is_err() {
eprintln!("[librebar] failed to write log entry to sink");
}
}
}
}
#[derive(Clone, Debug)]
struct SpanFields {
values: Map<String, Value>,
}
#[derive(Default)]
struct JsonVisitor {
values: Map<String, Value>,
}
impl tracing::field::Visit for JsonVisitor {
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
self.values
.insert(field.name().to_string(), Value::Bool(value));
}
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
self.values
.insert(field.name().to_string(), Value::Number(value.into()));
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.values
.insert(field.name().to_string(), Value::Number(value.into()));
}
fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
if let Some(number) = serde_json::Number::from_f64(value) {
self.values
.insert(field.name().to_string(), Value::Number(number));
}
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.values
.insert(field.name().to_string(), Value::String(value.to_string()));
}
fn record_error(
&mut self,
_field: &tracing::field::Field,
_value: &(dyn std::error::Error + 'static),
) {
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.values.insert(
field.name().to_string(),
Value::String(format!("{value:?}")),
);
}
}