diff --git a/foundations/src/telemetry/log/init.rs b/foundations/src/telemetry/log/init.rs index bee04c7..fee510b 100644 --- a/foundations/src/telemetry/log/init.rs +++ b/foundations/src/telemetry/log/init.rs @@ -121,6 +121,9 @@ pub(crate) fn init( (LogOutput::TracingRsCompat, _) => AsyncDrain::new(tracing_slog::TracingSlogDrain {}) .chan_size(CHANNEL_SIZE) .build_with_guard(), + (LogOutput::Custom(drain), _) => AsyncDrain::new(Arc::clone(drain)) + .chan_size(CHANNEL_SIZE) + .build_with_guard(), }; let root_drain = wrap_root_drain(settings, async_drain.fuse()); diff --git a/foundations/src/telemetry/log/testing.rs b/foundations/src/telemetry/log/testing.rs index c937630..d069dc0 100644 --- a/foundations/src/telemetry/log/testing.rs +++ b/foundations/src/telemetry/log/testing.rs @@ -2,7 +2,9 @@ use crate::telemetry::log::init::{LogHarness, build_log_with_drain, wrap_root_dr use crate::telemetry::log::internal::LoggerWithKvNestingTracking; use crate::telemetry::settings::LoggingSettings; use parking_lot::RwLock as ParkingRwLock; -use slog::{Discard, Drain, KV, Key, Level, Never, OwnedKVList, Record, Serializer}; +use slog::{ + Drain, KV, Key, Level, Never, OwnedKVList, Record, SendSyncRefUnwindSafeDrain, Serializer, +}; use std::fmt::Arguments; use std::sync::{Arc, RwLock}; @@ -69,14 +71,18 @@ where } } -pub(crate) fn create_test_log( +pub(crate) fn create_test_log( settings: &LoggingSettings, -) -> (LoggerWithKvNestingTracking, TestLogRecords) { + forward: Option, +) -> (LoggerWithKvNestingTracking, TestLogRecords) +where + D: SendSyncRefUnwindSafeDrain + 'static, +{ let log_records = Arc::new(RwLock::new(vec![])); - let drain: TestLogDrain = TestLogDrain { + let drain: TestLogDrain = TestLogDrain { records: Arc::clone(&log_records), - forward: None, + forward, }; let drain = wrap_root_drain(settings, drain); @@ -105,25 +111,5 @@ pub(crate) fn create_test_log_for_tracing_compat( crate::telemetry::settings::LogOutput::TracingRsCompat )); - let base_drain = TracingSlogDrain {}; - - let log_records = Arc::new(RwLock::new(vec![])); - - let drain = TestLogDrain { - records: Arc::clone(&log_records), - forward: Some(base_drain.fuse()), - }; - let drain = wrap_root_drain(settings, drain); - - let logger = build_log_with_drain(settings.verbosity, slog::o!(), Arc::clone(&drain)); - let log = LoggerWithKvNestingTracking::new(logger); - - let _ = LogHarness::override_for_testing(LogHarness { - root_log: Arc::new(ParkingRwLock::new(log.clone())), - root_drain: drain, - settings: settings.clone(), - log_scope_stack: Default::default(), - }); - - (log, log_records) + create_test_log(settings, Some(TracingSlogDrain {}.fuse())) } diff --git a/foundations/src/telemetry/settings/logging.rs b/foundations/src/telemetry/settings/logging.rs index 1068f3d..8e73a88 100644 --- a/foundations/src/telemetry/settings/logging.rs +++ b/foundations/src/telemetry/settings/logging.rs @@ -3,15 +3,23 @@ use crate::utils::feature_use; use std::path::PathBuf; +feature_use!(cfg(feature = "logging"), { + use slog::{Never, SendSyncRefUnwindSafeDrain}; + use std::sync::Arc; + + // NOTE: we technically don't need a feature gate here, but if we don't add + // it then docs don't mark this re-export as available on when `logging` is + // enabled. + pub use slog::Level; + + /// A custom slog drain for use with [`LogOutput::Custom`]. + pub type CustomDrain = Arc>; +}); + feature_use!(cfg(feature = "settings"), { use crate::settings::settings; }); -// NOTE: we technically don't need a feature gate here, but if we don't add it then docs don't -// mark this re-export as available on when `logging` is enabled. -#[cfg(feature = "logging")] -pub use slog::Level; - /// Logging settings. #[cfg_attr(feature = "settings", settings(crate_path = "crate"))] #[cfg_attr(not(feature = "settings"), derive(Clone, Default, Debug))] @@ -39,8 +47,11 @@ pub struct LoggingSettings { } /// Log output destination. -#[cfg_attr(feature = "settings", settings(crate_path = "crate"))] -#[cfg_attr(not(feature = "settings"), derive(Clone, Debug, Default))] +#[cfg_attr( + feature = "settings", + settings(crate_path = "crate", impl_debug = false) +)] +#[cfg_attr(not(feature = "settings"), derive(Clone, Default))] pub enum LogOutput { /// Write log to terminal. #[default] @@ -58,6 +69,48 @@ pub enum LogOutput { ///verbosity will not be respected #[cfg(feature = "tracing-rs-compat")] TracingRsCompat, + + /// User-provided drain. Not serializable — set programmatically only. + /// + /// [`LogFormat`] is ignored for this variant; the custom drain is responsible for its + /// own formatting. All other [`LoggingSettings`] (verbosity, field redaction, rate + /// limiting) still apply. + /// + /// # Examples + /// + /// Combine a terminal drain with a JSON file drain: + /// + /// ```ignore + /// use slog::{Drain, Duplicate}; + /// use slog_term::{FullFormat, TermDecorator}; + /// use slog_json::Json; + /// use std::fs::File; + /// use std::sync::Arc; + /// + /// let term = FullFormat::new(TermDecorator::new().build()).build().fuse(); + /// let file = File::create("/var/log/app.json").unwrap(); + /// let json = Json::new(file).build().fuse(); + /// let combined = Duplicate::new(term, json).fuse(); + /// + /// settings.logging.output = LogOutput::Custom(Arc::new(combined)); + /// ``` + #[cfg(feature = "logging")] + #[cfg_attr(feature = "settings", serde(skip))] + Custom(CustomDrain), +} + +impl std::fmt::Debug for LogOutput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Terminal => write!(f, "Terminal"), + Self::Stderr => write!(f, "Stderr"), + Self::File(path) => f.debug_tuple("File").field(path).finish(), + #[cfg(feature = "tracing-rs-compat")] + Self::TracingRsCompat => write!(f, "TracingRsCompat"), + #[cfg(feature = "logging")] + Self::Custom(_) => write!(f, "Custom(...)"), + } + } } /// Format of the log output. diff --git a/foundations/src/telemetry/testing.rs b/foundations/src/telemetry/testing.rs index ea95c86..16028c1 100644 --- a/foundations/src/telemetry/testing.rs +++ b/foundations/src/telemetry/testing.rs @@ -50,10 +50,13 @@ impl TestTelemetryContext { pub(crate) fn new() -> Self { #[cfg(feature = "logging")] let (log, log_records) = { - create_test_log(&LoggingSettings { - verbosity: LogVerbosity::Trace, - ..Default::default() - }) + create_test_log( + &LoggingSettings { + verbosity: LogVerbosity::Trace, + ..Default::default() + }, + None::, + ) }; #[cfg(feature = "tracing")] @@ -83,7 +86,22 @@ impl TestTelemetryContext { /// with the settings #[cfg(feature = "logging")] pub fn set_logging_settings(&mut self, logging_settings: LoggingSettings) { - let (log, log_records) = { create_test_log(&logging_settings) }; + let (log, log_records) = { create_test_log(&logging_settings, None::) }; + *self.inner.log.write() = log; + self.log_records = log_records; + } + + /// Overrides the logging settings on the test telemetry context with a custom drain. + /// + /// Records are forwarded to the custom drain **and** captured for assertions via + /// [`log_records`][Self::log_records]. + #[cfg(feature = "logging")] + pub fn set_custom_log_drain( + &mut self, + logging_settings: LoggingSettings, + custom_drain: super::settings::CustomDrain, + ) { + let (log, log_records) = create_test_log(&logging_settings, Some(custom_drain)); *self.inner.log.write() = log; self.log_records = log_records; } diff --git a/foundations/tests/custom_drain.rs b/foundations/tests/custom_drain.rs new file mode 100644 index 0000000..96fb823 --- /dev/null +++ b/foundations/tests/custom_drain.rs @@ -0,0 +1,41 @@ +use std::sync::{Arc, Mutex}; + +use foundations::telemetry::TestTelemetryContext; +use foundations::telemetry::log::error; +use foundations::telemetry::settings::LoggingSettings; +use slog::{Drain, Never, OwnedKVList, Record}; + +struct CapturingDrain { + messages: Arc>>, +} + +impl Drain for CapturingDrain { + type Ok = (); + type Err = Never; + + fn log(&self, record: &Record, _: &OwnedKVList) -> Result<(), Never> { + self.messages + .lock() + .unwrap() + .push(format!("{}", record.msg())); + Ok(()) + } +} + +#[foundations::telemetry::with_test_telemetry(test)] +fn custom_drain_receives_log_records(mut ctx: TestTelemetryContext) { + let messages = Arc::new(Mutex::new(Vec::new())); + let drain = CapturingDrain { + messages: Arc::clone(&messages), + }; + + ctx.set_custom_log_drain(LoggingSettings::default(), Arc::new(drain)); + + error!("hello from custom drain"); + + let msgs = messages.lock().unwrap(); + assert!( + msgs.iter().any(|m| m.contains("hello from custom drain")), + "custom drain did not receive the expected log record; got: {msgs:?}" + ); +}