use std::path::PathBuf;
use std::sync::RwLock;
pub use tracing_subscriber::filter::EnvFilter;
use tracing_subscriber::fmt::format;
use tracing_subscriber::fmt::{FmtContext, FormatEvent};
use tracing_subscriber::layer::{Layered, SubscriberExt};
use tracing_subscriber::{fmt, Layer, Registry};
use flush::FlushableWriter;
#[cfg(feature = "appinsights")]
pub mod appinsights;
pub mod file;
pub mod flush;
pub mod gate;
pub mod rate_limit;
pub mod reload;
pub mod sampling;
pub(crate) mod sinks;
pub mod size_rolling;
pub mod syslog;
pub mod error;
pub mod settings;
pub use error::Error;
pub use tracing;
pub type BaseStack = Layered<reload::ReloadFilterLayer, Registry>;
pub type CustomLayer = Box<dyn Layer<BaseStack> + Send + Sync>;
pub trait LineFormatter: Send + Sync + 'static {
fn format_line(
&self,
writer: &mut dyn std::fmt::Write,
event: &tracing::Event<'_>,
spans: &SpanScope,
) -> std::fmt::Result;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpanInfo {
pub name: &'static str,
pub target: String,
pub fields: String,
}
#[derive(Debug, Clone, Default)]
pub struct SpanScope {
spans: Vec<SpanInfo>,
}
impl SpanScope {
pub fn spans(&self) -> &[SpanInfo] {
&self.spans
}
pub fn current(&self) -> Option<&SpanInfo> {
self.spans.last()
}
pub fn is_empty(&self) -> bool {
self.spans.is_empty()
}
}
pub type EventFormatter = Box<dyn LineFormatter>;
pub(crate) struct WithGlobals<F> {
pub(crate) inner: F,
pub(crate) prefix: String,
pub(crate) redact: Vec<String>,
}
impl<F> WithGlobals<F> {
fn mask(&self, line: &str) -> String {
let mut out = line.to_string();
for key in &self.redact {
let mut from = 0;
while let Some(found) = out[from..].to_lowercase().find(key.as_str()) {
let start = from + found;
let after_key = start + key.len();
if out[after_key..].starts_with('=') {
let value_start = after_key + 1;
let value_end = if out[value_start..].starts_with('"') {
out[value_start + 1..]
.find('"')
.map_or(out.len(), |offset| value_start + 1 + offset + 1)
} else {
out[value_start..]
.find(char::is_whitespace)
.map_or(out.len(), |offset| value_start + offset)
};
out.replace_range(value_start..value_end, "[redacted]");
from = value_start + "[redacted]".len();
} else {
from = after_key;
}
if from >= out.len() {
break;
}
}
}
out
}
}
impl<S, N, F> FormatEvent<S, N> for WithGlobals<F>
where
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
N: for<'a> tracing_subscriber::fmt::FormatFields<'a> + 'static,
F: FormatEvent<S, N>,
{
fn format_event(
&self,
ctx: &FmtContext<'_, S, N>,
mut writer: format::Writer<'_>,
event: &tracing::Event<'_>,
) -> std::fmt::Result {
if !self.prefix.is_empty() {
write!(writer, "{} ", self.prefix)?;
}
if self.redact.is_empty() {
return self.inner.format_event(ctx, writer, event);
}
let mut buffer = String::new();
self.inner
.format_event(ctx, format::Writer::new(&mut buffer), event)?;
write!(writer, "{}", self.mask(&buffer))
}
}
pub(crate) struct BoxedFormat(pub(crate) EventFormatter);
impl<S, N> FormatEvent<S, N> for BoxedFormat
where
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
N: for<'a> tracing_subscriber::fmt::FormatFields<'a> + 'static,
{
fn format_event(
&self,
ctx: &FmtContext<'_, S, N>,
mut writer: format::Writer<'_>,
event: &tracing::Event<'_>,
) -> std::fmt::Result {
let mut spans = Vec::new();
if let Some(scope) = ctx.event_scope() {
for span in scope.from_root() {
let fields = span
.extensions()
.get::<tracing_subscriber::fmt::FormattedFields<N>>()
.map(|f| f.fields.clone())
.unwrap_or_default();
spans.push(SpanInfo {
name: span.name(),
target: span.metadata().target().to_string(),
fields,
});
}
}
self.0
.format_line(&mut writer, event, &SpanScope { spans })?;
writeln!(writer)
}
}
pub fn builder() -> Builder {
Builder::default()
}
pub fn handle() -> Option<Handle> {
LOGGING_HANDLE.read().ok()?.clone()
}
pub fn is_initialized() -> bool {
LOGGING_HANDLE.read().map(|g| g.is_some()).unwrap_or(false)
}
pub fn reload_filter(filter: EnvFilter) -> Result<(), Error> {
let guard = LOGGING_HANDLE.read().map_err(|_| Error::LockPoisoned)?;
let handle = guard.as_ref().ok_or(Error::NotInitialized)?;
handle
.reload_tx
.as_ref()
.ok_or(Error::ReloadNotEnabled)?
.reload(filter)?;
Ok(())
}
pub fn flush() -> Result<(), Error> {
let guard = LOGGING_HANDLE.read().map_err(|_| Error::LockPoisoned)?;
let handle = guard.as_ref().ok_or(Error::NotInitialized)?;
flush_handle(handle);
Ok(())
}
pub fn reset() {
if let Ok(mut g) = LOGGING_HANDLE.write() {
*g = None;
}
}
#[derive(Default)]
pub struct Builder {
console: Option<ConsoleConfig>,
json: Option<JsonConfig>,
file: Option<FileConfig>,
filter: Option<EnvFilter>,
reloadable: bool,
queue_size: Option<usize>,
rate_limit: Option<rate_limit::RateLimit>,
sampling: Option<sampling::SampleConfig>,
layers: Vec<CustomLayer>,
console_format: Option<EventFormatter>,
file_format: Option<EventFormatter>,
capture_panics: bool,
global_fields: Vec<(String, String)>,
redact_keys: Vec<String>,
syslog: Option<syslog::SyslogConfig>,
#[cfg(feature = "appinsights")]
app_insights: Option<appinsights::AppInsightsConfig>,
sink_filters: SinkFilters,
}
#[derive(Debug, Clone, Default)]
struct SinkFilters {
console: Option<String>,
json: Option<String>,
file: Option<String>,
syslog: Option<String>,
#[cfg(feature = "appinsights")]
app_insights: Option<String>,
}
struct ParsedSinkFilters {
console: Option<EnvFilter>,
json: Option<EnvFilter>,
file: Option<EnvFilter>,
syslog: Option<EnvFilter>,
#[cfg(feature = "appinsights")]
app_insights: Option<EnvFilter>,
}
impl SinkFilters {
fn parse(&self) -> Result<ParsedSinkFilters, Error> {
fn one(directives: &Option<String>, sink: &str) -> Result<Option<EnvFilter>, Error> {
match directives {
None => Ok(None),
Some(d) => EnvFilter::try_new(d)
.map(Some)
.map_err(|e| Error::InvalidFilter(format!("{sink}: {e}"))),
}
}
Ok(ParsedSinkFilters {
console: one(&self.console, "console")?,
json: one(&self.json, "json")?,
file: one(&self.file, "file")?,
syslog: one(&self.syslog, "syslog")?,
#[cfg(feature = "appinsights")]
app_insights: one(&self.app_insights, "app_insights")?,
})
}
}
impl Builder {
pub fn console(mut self, config: ConsoleConfig) -> Self {
self.console = Some(config);
self
}
pub fn json(mut self, config: JsonConfig) -> Self {
self.json = Some(config);
self
}
pub fn file(mut self, config: FileConfig) -> Self {
self.file = Some(config);
self
}
pub fn with_layer<L>(mut self, layer: L) -> Self
where
L: Layer<BaseStack> + Send + Sync + 'static,
{
self.layers.push(Box::new(layer));
self
}
pub fn console_format<F>(mut self, format: F) -> Self
where
F: LineFormatter,
{
self.console_format = Some(Box::new(format));
self
}
pub fn file_format<F>(mut self, format: F) -> Self
where
F: LineFormatter,
{
self.file_format = Some(Box::new(format));
self
}
pub fn capture_panics(mut self) -> Self {
self.capture_panics = true;
self
}
pub fn global_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
let key = key.into();
self.global_fields.retain(|(existing, _)| existing != &key);
self.global_fields.push((key, value.into()));
self
}
pub fn redact<I, K>(mut self, keys: I) -> Self
where
I: IntoIterator<Item = K>,
K: AsRef<str>,
{
self.redact_keys
.extend(keys.into_iter().map(|k| k.as_ref().to_lowercase()));
self
}
#[cfg(feature = "appinsights")]
pub fn app_insights(mut self, config: appinsights::AppInsightsConfig) -> Self {
self.app_insights = Some(config);
self
}
pub fn syslog(mut self, config: syslog::SyslogConfig) -> Self {
self.syslog = Some(config);
self
}
pub fn console_filter(mut self, directives: impl Into<String>) -> Self {
self.sink_filters.console = Some(directives.into());
self
}
pub fn json_filter(mut self, directives: impl Into<String>) -> Self {
self.sink_filters.json = Some(directives.into());
self
}
pub fn file_filter(mut self, directives: impl Into<String>) -> Self {
self.sink_filters.file = Some(directives.into());
self
}
pub fn syslog_filter(mut self, directives: impl Into<String>) -> Self {
self.sink_filters.syslog = Some(directives.into());
self
}
#[cfg(feature = "appinsights")]
pub fn app_insights_filter(mut self, directives: impl Into<String>) -> Self {
self.sink_filters.app_insights = Some(directives.into());
self
}
pub fn with_filter(mut self, filter: EnvFilter) -> Self {
self.filter = Some(filter);
self
}
pub fn reloadable(mut self) -> Self {
self.reloadable = true;
self
}
pub fn queue_size(mut self, size: usize) -> Self {
self.queue_size = Some(size);
self
}
pub fn rate_limit(mut self, limit: rate_limit::RateLimit) -> Self {
self.rate_limit = Some(limit);
self
}
pub fn sampling(mut self, config: sampling::SampleConfig) -> Self {
self.sampling = Some(config);
self
}
pub fn build(self) -> Result<(impl tracing::Subscriber + Send + Sync, Handle), Error> {
if self.capture_panics {
install_panic_hook();
}
let filter = self
.filter
.unwrap_or_else(|| EnvFilter::try_from_default_env().unwrap_or(EnvFilter::new("info")));
let queue = self.queue_size.unwrap_or(128_000);
let sink_filters = self.sink_filters.parse()?;
let redact = self.redact_keys;
let globals = sinks::render_globals(&self.global_fields);
let (rx, tx) = reload::ReloadFilterLayer::new(filter);
let reload_tx = if self.reloadable { Some(tx) } else { None };
let gate_layer = gate::layer_for(self.sampling, self.rate_limit);
let console = sinks::console_group(
self.console.as_ref(),
queue,
&globals,
&redact,
self.console_format,
&sink_filters.console,
);
let json = sinks::json_group(self.json.as_ref(), queue, &sink_filters.json);
let file_writer = self
.file
.as_ref()
.map(|fc| build_file_writer(fc, queue))
.transpose()?;
let file = sinks::file_group(
self.file.as_ref(),
file_writer,
&globals,
&redact,
self.file_format,
&sink_filters.file,
);
let syslog_layer = sinks::syslog_group(self.syslog, &sink_filters.syslog);
#[cfg(feature = "appinsights")]
let app_insights = appinsights::export(self.app_insights, &sink_filters.app_insights)?;
let subscriber = Registry::default()
.with(rx)
.with((!self.layers.is_empty()).then_some(self.layers))
.with(gate_layer)
.with(console.layer)
.with(json.layer)
.with(file.layer)
.with(syslog_layer);
#[cfg(feature = "appinsights")]
let subscriber = subscriber.with(app_insights.traces).with(app_insights.logs);
let handle = Handle {
#[cfg(feature = "appinsights")]
app_insights: app_insights.providers,
reload_tx,
console: console.writer,
json: json.writer,
file: file.writer,
};
Ok((subscriber, handle))
}
pub fn init(self) -> Result<Handle, Error> {
if is_initialized() {
return Err(Error::AlreadyInitialized);
}
let (subscriber, handle) = self.build()?;
let mut guard = LOGGING_HANDLE.write().map_err(|_| Error::LockPoisoned)?;
if guard.is_some() {
return Err(Error::AlreadyInitialized);
}
tracing::subscriber::set_global_default(subscriber)?;
*guard = Some(handle.clone());
Ok(handle)
}
}
const LOG_FILE_PREFIX: &str = "app.log";
fn build_file_writer(fc: &FileConfig, queue: usize) -> Result<FlushableWriter, Error> {
let path = PathBuf::from(&fc.directory);
std::fs::create_dir_all(&path)?;
if fc.retention_days > 0 {
file::cleanup_old_files(&fc.directory, &fc.prefix, fc.retention_days);
}
if let file::Rotation::Size {
max_bytes,
max_files,
} = fc.rotation
{
let directory = fc.directory.clone();
let prefix = fc.prefix.clone();
let compress = fc.compress;
size_rolling::SizeRollingWriter::new(&directory, &prefix, max_bytes, max_files, compress)?;
let open = move || -> Box<dyn std::io::Write + Send> {
match size_rolling::SizeRollingWriter::new(
&directory, &prefix, max_bytes, max_files, compress,
) {
Ok(writer) => Box::new(writer),
Err(e) => {
eprintln!(
"stratify::logging: cannot reopen the log file, discarding output: {e}"
);
Box::new(std::io::sink())
}
}
};
return Ok(FlushableWriter::new(open, queue, true));
}
let rotation = match fc.rotation {
file::Rotation::Daily => tracing_appender::rolling::Rotation::DAILY,
file::Rotation::Hourly => tracing_appender::rolling::Rotation::HOURLY,
file::Rotation::Never => tracing_appender::rolling::Rotation::NEVER,
file::Rotation::Size { .. } => tracing_appender::rolling::Rotation::NEVER,
};
let directory = fc.directory.clone();
let prefix = fc.prefix.clone();
let open_appender = move || {
tracing_appender::rolling::RollingFileAppender::new(rotation.clone(), &directory, &prefix)
};
Ok(FlushableWriter::new(open_appender, queue, true))
}
fn flush_handle(handle: &Handle) {
for writer in [&handle.console, &handle.json, &handle.file]
.into_iter()
.flatten()
{
writer.drain();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConsoleTarget {
#[default]
Stderr,
Stdout,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TimestampFormat {
#[default]
Utc,
Local,
None,
}
fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let message = info
.payload()
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| info.payload().downcast_ref::<String>().cloned())
.unwrap_or_else(|| "panic with a non-string payload".to_string());
match info.location() {
Some(location) => tracing::error!(
panic.message = %message,
panic.file = %location.file(),
panic.line = location.line(),
"thread panicked"
),
None => tracing::error!(panic.message = %message, "thread panicked"),
}
previous(info);
}));
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Timestamp(pub(crate) TimestampFormat);
impl fmt::time::FormatTime for Timestamp {
fn format_time(&self, writer: &mut format::Writer<'_>) -> std::fmt::Result {
use time::format_description::well_known::Rfc3339;
let now = match self.0 {
TimestampFormat::None => return Ok(()),
TimestampFormat::Utc => time::OffsetDateTime::now_utc(),
TimestampFormat::Local => time::OffsetDateTime::now_local()
.unwrap_or_else(|_| time::OffsetDateTime::now_utc()),
};
match now.format(&Rfc3339) {
Ok(rendered) => write!(writer, "{rendered}"),
Err(_) => Err(std::fmt::Error),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FileFormat {
#[default]
Json,
Text,
}
pub struct FileConfig {
pub directory: String,
pub rotation: file::Rotation,
pub retention_days: u32,
pub json_config: JsonConfig,
pub format: FileFormat,
pub prefix: String,
pub timestamp: TimestampFormat,
pub compress: bool,
}
impl FileConfig {
pub fn new(directory: impl Into<String>) -> Self {
Self {
directory: directory.into(),
rotation: file::Rotation::Daily,
retention_days: 0,
json_config: JsonConfig::default(),
format: FileFormat::default(),
prefix: LOG_FILE_PREFIX.to_string(),
timestamp: TimestampFormat::default(),
compress: false,
}
}
pub fn with_rotation(mut self, rotation: file::Rotation) -> Self {
self.rotation = rotation;
self
}
pub fn with_retention_days(mut self, days: u32) -> Self {
self.retention_days = days;
self
}
pub fn with_format(mut self, format: FileFormat) -> Self {
self.format = format;
self
}
pub fn with_compression(mut self, compress: bool) -> Self {
self.compress = compress;
self
}
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}
pub fn with_timestamp(mut self, timestamp: TimestampFormat) -> Self {
self.timestamp = timestamp;
self
}
pub fn with_json_config(mut self, config: JsonConfig) -> Self {
self.json_config = config;
self
}
}
impl Default for FileConfig {
fn default() -> Self {
Self::new("/var/log/app")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct ConsoleConfig {
pub target_stream: ConsoleTarget,
pub timestamp: TimestampFormat,
pub use_color: bool,
pub thread_ids: bool,
pub target: bool,
pub lossy: bool,
}
impl Default for ConsoleConfig {
fn default() -> Self {
Self {
target_stream: ConsoleTarget::default(),
timestamp: TimestampFormat::default(),
use_color: true,
thread_ids: true,
target: true,
lossy: false,
}
}
}
impl ConsoleConfig {
pub fn with_target_stream(mut self, target: ConsoleTarget) -> Self {
self.target_stream = target;
self
}
pub fn with_timestamp(mut self, timestamp: TimestampFormat) -> Self {
self.timestamp = timestamp;
self
}
pub fn with_color(mut self, enabled: bool) -> Self {
self.use_color = enabled;
self
}
pub fn with_thread_ids(mut self, enabled: bool) -> Self {
self.thread_ids = enabled;
self
}
pub fn with_target(mut self, enabled: bool) -> Self {
self.target = enabled;
self
}
pub fn with_lossy(mut self, enabled: bool) -> Self {
self.lossy = enabled;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct JsonConfig {
pub span_list: bool,
pub flatten: bool,
pub lossy: bool,
}
impl Default for JsonConfig {
fn default() -> Self {
Self {
span_list: true,
flatten: true,
lossy: false,
}
}
}
impl JsonConfig {
pub fn with_span_list(mut self, enabled: bool) -> Self {
self.span_list = enabled;
self
}
pub fn with_flatten(mut self, enabled: bool) -> Self {
self.flatten = enabled;
self
}
pub fn with_lossy(mut self, enabled: bool) -> Self {
self.lossy = enabled;
self
}
}
#[derive(Debug, Clone)]
pub struct Handle {
reload_tx: Option<reload::ReloadHandle>,
console: Option<FlushableWriter>,
json: Option<FlushableWriter>,
file: Option<FlushableWriter>,
#[cfg(feature = "appinsights")]
app_insights: Option<appinsights::Providers>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct DroppedLines {
pub console: usize,
pub json: usize,
pub file: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct QueueDepth {
pub console: usize,
pub json: usize,
pub file: usize,
}
impl QueueDepth {
pub fn total(&self) -> usize {
self.console + self.json + self.file
}
pub fn max(&self) -> usize {
self.console.max(self.json).max(self.file)
}
}
impl DroppedLines {
pub fn total(&self) -> usize {
self.console + self.json + self.file
}
pub fn any(&self) -> bool {
self.total() > 0
}
}
impl Handle {
pub fn queue_depth(&self) -> QueueDepth {
QueueDepth {
console: self.console.as_ref().map_or(0, |w| w.queue_depth()),
json: self.json.as_ref().map_or(0, |w| w.queue_depth()),
file: self.file.as_ref().map_or(0, |w| w.queue_depth()),
}
}
pub fn dropped_lines(&self) -> DroppedLines {
DroppedLines {
console: self.console.as_ref().map_or(0, |w| w.dropped_lines()),
json: self.json.as_ref().map_or(0, |w| w.dropped_lines()),
file: self.file.as_ref().map_or(0, |w| w.dropped_lines()),
}
}
pub fn flush(&self) {
#[cfg(feature = "appinsights")]
if let Some(providers) = &self.app_insights {
providers.force_flush();
}
flush_handle(self);
}
pub fn shutdown(self) {
self.flush();
#[cfg(feature = "appinsights")]
if let Some(providers) = &self.app_insights {
providers.shutdown();
}
}
}
pub fn request_context(request_id: &str, method: &str, path: &str) -> tracing::Span {
tracing::info_span!(
"request",
request_id = %request_id,
method = %method,
path = %path,
)
}
static LOGGING_HANDLE: RwLock<Option<Handle>> = RwLock::new(None);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn console_config_defaults() {
let c = ConsoleConfig::default();
assert!(c.use_color);
assert!(c.thread_ids);
assert!(c.target);
assert!(!c.lossy);
}
#[test]
fn json_config_defaults() {
let j = JsonConfig::default();
assert!(j.span_list);
assert!(j.flatten);
assert!(!j.lossy);
}
#[test]
fn file_config_defaults() {
let f = FileConfig::default();
assert_eq!(f.directory, "/var/log/app");
}
#[test]
fn builder_methods_are_chainable() {
let b = super::builder()
.console(ConsoleConfig::default())
.json(JsonConfig::default())
.file(FileConfig::new("/tmp/test"))
.reloadable()
.queue_size(64_000)
.with_filter(EnvFilter::builder().parse("debug").unwrap());
let _: Builder = b;
}
#[test]
fn request_context_creates_span() {
let span = request_context("r1", "GET", "/api");
drop(span);
}
#[test]
fn build_is_testable() {
let (subscriber, handle) = super::builder()
.console(ConsoleConfig::default())
.build()
.unwrap();
let _guard = tracing::subscriber::set_default(subscriber);
assert!(handle.reload_tx.is_none());
assert!(handle.console.is_some());
assert!(handle.json.is_none());
assert!(handle.file.is_none());
}
#[test]
fn build_with_reload() {
let (subscriber, handle) = super::builder().reloadable().build().unwrap();
let _guard = tracing::subscriber::set_default(subscriber);
assert!(handle.reload_tx.is_some());
}
#[test]
fn build_with_file() {
let dir = std::env::temp_dir().join("stratify_logging_build_test");
let _ = std::fs::create_dir_all(&dir);
let (subscriber, handle) = super::builder()
.file(FileConfig::new(dir.to_string_lossy().as_ref()))
.build()
.unwrap();
let _guard = tracing::subscriber::set_default(subscriber);
assert!(handle.file.is_some());
}
#[test]
fn build_all_layers() {
let dir = std::env::temp_dir().join("stratify_logging_all_layers_test");
let _ = std::fs::create_dir_all(&dir);
let (subscriber, handle) = super::builder()
.console(ConsoleConfig::default())
.json(JsonConfig::default())
.file(FileConfig::new(dir.to_string_lossy().as_ref()))
.build()
.unwrap();
let _guard = tracing::subscriber::set_default(subscriber);
assert!(handle.console.is_some());
assert!(handle.json.is_some());
assert!(handle.file.is_some());
}
#[test]
fn lossy_mode_present_on_configs() {
let cc = ConsoleConfig::default().with_lossy(true);
assert!(cc.lossy);
let jc = JsonConfig::default().with_lossy(true);
assert!(jc.lossy);
}
#[test]
fn custom_queue_size() {
let (subscriber, _handle) = super::builder().queue_size(1_000).build().unwrap();
let _guard = tracing::subscriber::set_default(subscriber);
}
#[test]
fn handle_returns_none_before_init() {
assert!(handle().is_none());
assert!(!is_initialized());
}
#[test]
fn reset_clears_handle() {
assert!(!is_initialized());
reset(); assert!(!is_initialized());
}
#[test]
fn clone_carries_the_same_writers() {
let (_, handle) = super::builder()
.console(ConsoleConfig::default())
.json(JsonConfig::default())
.build()
.unwrap();
let cloned = handle.clone();
assert_eq!(cloned.reload_tx.is_some(), handle.reload_tx.is_some());
assert!(cloned.console.is_some());
assert!(cloned.json.is_some());
assert!(cloned.file.is_none(), "no file layer was configured");
}
#[test]
fn handle_flush_does_not_panic() {
let (subscriber, handle) = super::builder()
.console(ConsoleConfig::default())
.build()
.unwrap();
let _guard = tracing::subscriber::set_default(subscriber);
handle.flush(); }
#[test]
fn file_config_new_accepts_custom_path() {
let fc = FileConfig::new("/custom/log/path");
assert_eq!(fc.directory, "/custom/log/path");
}
#[test]
fn file_config_default_rotation_is_daily() {
let fc = FileConfig::default();
assert_eq!(fc.rotation, file::Rotation::Daily);
}
#[test]
fn build_file_with_hourly_rotation() {
let dir = std::env::temp_dir().join("stratify_logging_hourly_test");
let _ = std::fs::create_dir_all(&dir);
let fc =
FileConfig::new(dir.to_string_lossy().as_ref()).with_rotation(file::Rotation::Hourly);
let (subscriber, handle) = super::builder().file(fc).build().unwrap();
let _guard = tracing::subscriber::set_default(subscriber);
assert!(handle.file.is_some());
}
#[test]
fn build_file_with_never_rotation() {
let dir = std::env::temp_dir().join("stratify_logging_never_test");
let _ = std::fs::create_dir_all(&dir);
let fc =
FileConfig::new(dir.to_string_lossy().as_ref()).with_rotation(file::Rotation::Never);
let (subscriber, handle) = super::builder().file(fc).build().unwrap();
let _guard = tracing::subscriber::set_default(subscriber);
assert!(handle.file.is_some());
}
#[test]
fn build_file_creates_nonexistent_directory() {
let dir = std::env::temp_dir().join("stratify_logging_auto_create_test");
let _ = std::fs::remove_dir_all(&dir);
let (subscriber, handle) = super::builder()
.file(FileConfig::new(dir.to_string_lossy().as_ref()))
.build()
.unwrap();
let _guard = tracing::subscriber::set_default(subscriber);
assert!(handle.file.is_some());
assert!(dir.exists());
}
#[test]
fn console_layer_writes_to_stderr() {
let (subscriber, _handle) = super::builder()
.console(ConsoleConfig::default())
.with_filter(EnvFilter::new("info"))
.build()
.unwrap();
let _guard = tracing::subscriber::set_default(subscriber);
tracing::info!("console smoke test");
tracing::error!("console error smoke");
tracing::warn!("console warn smoke");
}
#[test]
fn json_layer_writes_to_stdout() {
let (subscriber, _handle) = super::builder()
.json(JsonConfig::default())
.with_filter(EnvFilter::new("info"))
.build()
.unwrap();
let _guard = tracing::subscriber::set_default(subscriber);
tracing::info!(key = "value", "json smoke test");
tracing::error!(code = 500, "json error smoke");
}
#[test]
fn default_filter_when_none_supplied() {
let (subscriber, _handle) = super::builder()
.console(ConsoleConfig::default())
.build()
.unwrap();
let _guard = tracing::subscriber::set_default(subscriber);
tracing::info!("default filter test");
}
#[test]
fn reloadable_returns_some_handle_when_enabled() {
let (subscriber, handle) = super::builder().reloadable().build().unwrap();
let _guard = tracing::subscriber::set_default(subscriber);
assert!(handle.reload_tx.is_some());
}
#[test]
fn reloadable_returns_none_when_not_enabled() {
let (subscriber, handle) = super::builder()
.console(ConsoleConfig::default())
.build()
.unwrap();
let _guard = tracing::subscriber::set_default(subscriber);
assert!(handle.reload_tx.is_none());
}
#[test]
fn request_context_includes_all_fields() {
let span = request_context("req-42", "POST", "/users");
let _enter = span.enter();
tracing::info!("inside request span");
}
#[test]
fn error_paths_are_covered() {
assert!(flush().is_err());
let result = reload_filter(EnvFilter::new("debug"));
assert!(result.is_err()); }
}