use std::io::IsTerminal;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_appender::rolling;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::Registry;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, Layer};
use super::config::{FileLogConfig, LogFormat, LoggerConfig, Rotation};
use super::event::set_error_log_detail;
use super::format::{ConsoleFormat, JsonFormat, TorkFormat};
type BoxLayer = Box<dyn Layer<Registry> + Send + Sync>;
#[must_use = "dropping the LoggerHandle stops background log workers"]
pub(crate) struct LoggerHandle {
_guards: Vec<WorkerGuard>,
#[cfg(feature = "otel")]
otel_provider: Option<opentelemetry_sdk::trace::SdkTracerProvider>,
}
#[cfg(feature = "otel")]
impl Drop for LoggerHandle {
fn drop(&mut self) {
if let Some(provider) = &self.otel_provider {
let _ = provider.shutdown();
}
}
}
pub(crate) fn install(config: &LoggerConfig) -> LoggerHandle {
set_error_log_detail(config.error_detail);
let mut guards = Vec::new();
let mut layers: Vec<BoxLayer> = Vec::new();
layers.push(stdout_layer(config, &mut guards));
if let Some(file) = &config.file {
layers.push(file_layer(config, file, &mut guards));
}
#[cfg(feature = "otel")]
let otel_provider = config.telemetry.as_ref().and_then(|telemetry| {
otel_layer(config, telemetry).map(|(layer, provider)| {
layers.push(layer);
provider
})
});
let _ = Registry::default().with(layers).try_init();
LoggerHandle {
_guards: guards,
#[cfg(feature = "otel")]
otel_provider,
}
}
#[cfg(feature = "otel")]
fn otel_layer(
config: &LoggerConfig,
telemetry: &super::config::TelemetryConfig,
) -> Option<(BoxLayer, opentelemetry_sdk::trace::SdkTracerProvider)> {
use opentelemetry::trace::TracerProvider as _;
use opentelemetry_otlp::WithExportConfig as _;
if !telemetry.enabled {
return None;
}
let exporter = opentelemetry_otlp::SpanExporter::builder()
.with_tonic()
.with_endpoint(&telemetry.otlp_endpoint)
.build()
.ok()?;
let resource = opentelemetry_sdk::Resource::builder()
.with_service_name(telemetry.service_name.clone())
.build();
let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
.with_batch_exporter(exporter)
.with_resource(resource)
.build();
let tracer = provider.tracer("tork");
let layer = tracing_opentelemetry::layer()
.with_tracer(tracer)
.with_filter(env_filter(&config.level))
.boxed();
Some((layer, provider))
}
fn env_filter(level: &str) -> EnvFilter {
EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(level))
.unwrap_or_else(|_| EnvFilter::new("info"))
}
fn stdout_layer(config: &LoggerConfig, guards: &mut Vec<WorkerGuard>) -> BoxLayer {
let format = build_format(config);
if config.non_blocking {
let (writer, guard) = tracing_appender::non_blocking(std::io::stdout());
guards.push(guard);
tracing_subscriber::fmt::layer()
.event_format(format)
.with_writer(writer)
.with_filter(env_filter(&config.level))
.boxed()
} else {
tracing_subscriber::fmt::layer()
.event_format(format)
.with_writer(std::io::stdout)
.with_filter(env_filter(&config.level))
.boxed()
}
}
fn file_layer(
config: &LoggerConfig,
file: &FileLogConfig,
guards: &mut Vec<WorkerGuard>,
) -> BoxLayer {
let _ = std::fs::create_dir_all(&file.directory);
let appender = match file.rotation {
Rotation::Never => rolling::never(&file.directory, &file.prefix),
Rotation::Hourly => rolling::hourly(&file.directory, &file.prefix),
Rotation::Daily => rolling::daily(&file.directory, &file.prefix),
};
let format = TorkFormat::Json(JsonFormat {
service_name: config.service_name.clone(),
});
if file.non_blocking {
let (writer, guard) = tracing_appender::non_blocking(appender);
guards.push(guard);
tracing_subscriber::fmt::layer()
.event_format(format)
.with_writer(writer)
.with_filter(env_filter(&config.level))
.boxed()
} else {
tracing_subscriber::fmt::layer()
.event_format(format)
.with_writer(appender)
.with_filter(env_filter(&config.level))
.boxed()
}
}
fn build_format(config: &LoggerConfig) -> TorkFormat {
let json = match config.format {
LogFormat::Json => true,
LogFormat::Pretty | LogFormat::Compact => false,
LogFormat::Auto => !std::io::stdout().is_terminal(),
};
if json {
TorkFormat::Json(JsonFormat {
service_name: config.service_name.clone(),
})
} else {
TorkFormat::Console(ConsoleFormat {
color: config.color && std::io::stdout().is_terminal(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::env::env_guard;
use tracing_subscriber::layer::SubscriberExt;
#[test]
fn env_filter_uses_explicit_level_and_fallback() {
let _guard = env_guard();
std::env::remove_var("RUST_LOG");
let debug = env_filter("debug");
assert_eq!(debug.to_string(), "debug");
let fallback = env_filter("[");
assert_eq!(fallback.to_string(), "info");
std::env::set_var("RUST_LOG", "warn,tork=trace");
let from_env = env_filter("error");
let rendered = from_env.to_string();
assert!(rendered.contains("warn"));
assert!(rendered.contains("tork=trace"));
std::env::remove_var("RUST_LOG");
}
#[test]
fn build_format_honors_explicit_json_and_console_preferences() {
let json = build_format(
&LoggerConfig::new()
.format(LogFormat::Json)
.service_name("svc"),
);
match json {
TorkFormat::Json(format) => assert_eq!(format.service_name, "svc"),
_ => panic!("expected json formatter"),
}
let console = build_format(&LoggerConfig::new().format(LogFormat::Pretty).color(false));
match console {
TorkFormat::Console(format) => assert!(!format.color),
_ => panic!("expected console formatter"),
}
let compact = build_format(&LoggerConfig::new().format(LogFormat::Compact));
assert!(matches!(compact, TorkFormat::Console(_)));
}
#[test]
fn stdout_and_file_layers_cover_blocking_and_non_blocking_paths() {
let mut guards = Vec::new();
let config = LoggerConfig::new()
.format(LogFormat::Json)
.non_blocking(true);
let _ = stdout_layer(&config, &mut guards);
assert_eq!(guards.len(), 1);
let mut no_guards = Vec::new();
let _ = stdout_layer(
&LoggerConfig::new().format(LogFormat::Pretty),
&mut no_guards,
);
assert!(no_guards.is_empty());
let dir = tempfile::tempdir().unwrap();
let file = FileLogConfig::new(dir.path())
.prefix("hourly")
.rotation(Rotation::Hourly)
.non_blocking(true);
let mut file_guards = Vec::new();
let _ = file_layer(&LoggerConfig::new().level("debug"), &file, &mut file_guards);
assert_eq!(file_guards.len(), 1);
let daily = FileLogConfig::new(dir.path())
.prefix("daily")
.rotation(Rotation::Daily)
.non_blocking(false);
let mut daily_guards = Vec::new();
let _ = file_layer(&LoggerConfig::new(), &daily, &mut daily_guards);
assert!(daily_guards.is_empty());
}
#[test]
fn install_accepts_file_sink_configuration() {
let dir = tempfile::tempdir().unwrap();
let config = LoggerConfig::new().file(
FileLogConfig::new(dir.path())
.prefix("app")
.rotation(Rotation::Never)
.non_blocking(false),
);
let _handle = install(&config);
}
#[test]
fn file_layer_writes_json_records() {
let dir = tempfile::tempdir().unwrap();
let appender = rolling::never(dir.path(), "test.log");
let layer = tracing_subscriber::fmt::layer()
.event_format(TorkFormat::Json(JsonFormat {
service_name: "svc".to_owned(),
}))
.with_writer(appender);
let subscriber = Registry::default().with(layer);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(tork.context = "FileTest", tork.fields = "{}", "to a file");
});
let path = dir.path().join("test.log");
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("\"context\":\"FileTest\""), "{contents}");
assert!(contents.contains("\"message\":\"to a file\""), "{contents}");
}
#[cfg(feature = "otel")]
#[tokio::test]
async fn otel_layer_builds_from_config() {
use super::super::config::TelemetryConfig;
let config = LoggerConfig::new();
let telemetry = TelemetryConfig::new("http://localhost:4317").service_name("test");
let built = otel_layer(&config, &telemetry);
assert!(built.is_some());
if let Some((_, provider)) = built {
let _ = provider.shutdown();
}
}
}