use bon::Builder;
#[derive(Builder)]
#[allow(
clippy::struct_excessive_bools,
reason = "builder toggles — each bool is an independent documented capability"
)]
pub struct Deployment {
level: Option<log::LevelFilter>,
#[builder(default = true)]
stdout: bool,
#[builder(default)]
layout: LayoutChoice,
#[builder(default)]
file_from_env: bool,
backends: Option<crate::config::Backends>,
error_hook_throttle: Option<u32>,
#[builder(default)]
traces: TracesChoice,
#[builder(default = true)]
panic_hook: bool,
#[builder(default = true)]
flush_on_exit: bool,
#[builder(default)]
syslog: bool,
#[builder(default)]
journald: bool,
#[builder(default)]
async_append: bool,
#[builder(default)]
task_local_diagnostic: bool,
#[builder(default)]
rust_log_filter: bool,
stderr_from: Option<log::Level>,
#[builder(default)]
static_diag: Vec<(String, String)>,
}
#[derive(Debug, Clone, Copy, Default, strum::EnumString)]
#[strum(serialize_all = "lowercase")]
pub enum LayoutChoice {
#[default]
Text,
Json,
Logfmt,
Gcl,
}
#[derive(Default, strum::EnumString)]
#[strum(serialize_all = "lowercase")]
pub enum TracesChoice {
#[default]
Console,
#[cfg(feature = "fastrace")]
#[strum(disabled)]
ConsoleWith(fastrace::collector::Config),
#[cfg(feature = "fastrace")]
#[strum(disabled)]
Custom(
Box<dyn fastrace::collector::Reporter>,
fastrace::collector::Config,
),
Off,
}
#[allow(
unused_macros,
reason = "used when any of the five feature-gated deployment toggles' cargo features is off"
)]
macro_rules! missing_feature_warn {
($toggle:literal, $feature:literal, $tail:literal) => {{
log::warn!(
target: crate::log_targets::DEPLOY,
concat!(
$toggle,
" requested but cargo feature `",
$feature,
"` is not compiled in — ",
$tail
)
);
}};
}
pub fn observe() -> DeploymentBuilder {
Deployment::builder()
}
impl<S: deployment_builder::State> DeploymentBuilder<S> {
pub fn init(self) -> Result<InitGuard, InitError> {
self.build().wire()
}
}
impl DeploymentConfig {
#[must_use]
pub fn from_env() -> Self {
Self {
level: std::env::var(crate::env_vars::OBSERVE_LOG).ok(),
stdout: None,
layout: None,
file_from_env: std::env::var_os(crate::env_vars::OBSERVE_LOG_DIR)
.is_some()
.then_some(true),
backends: std::env::var(crate::env_vars::OBSERVE_PROFILE).ok(),
error_hook_throttle: std::env::var(crate::env_vars::OBSERVE_ERROR_THROTTLE)
.ok()
.and_then(|v| v.parse().ok()),
traces: None,
panic_hook: None,
flush_on_exit: None,
syslog: None,
journald: None,
async_append: None,
task_local_diagnostic: None,
rust_log_filter: None,
}
}
}
impl Deployment {
pub fn from_env() -> Result<Self, Vec<ConfigError>> {
DeploymentConfig::from_env().apply(observe())
}
pub fn from_config(cfg: DeploymentConfig) -> Result<Self, Vec<ConfigError>> {
cfg.apply(observe())
}
pub fn init(self) -> Result<InitGuard, InitError> {
self.wire()
}
#[allow(
clippy::too_many_lines,
reason = "one sequential pipeline: config → appenders → dispatch → reporter → hooks — splitting would scatter a single logical flow"
)]
fn wire(self) -> Result<InitGuard, InitError> {
let Self {
level,
stdout,
layout,
file_from_env,
backends,
error_hook_throttle,
traces,
panic_hook,
flush_on_exit,
syslog,
journald,
async_append,
task_local_diagnostic,
rust_log_filter,
stderr_from,
static_diag,
} = self;
#[cfg(not(feature = "file"))]
let _ = file_from_env;
#[cfg(not(feature = "fastrace"))]
let _ = traces;
#[cfg(not(all(feature = "flush-on-exit", not(target_family = "wasm"))))]
let _ = flush_on_exit;
if let Some(backends) = backends {
crate::config::config().set_backends(backends);
}
if let Some(max_per_second) = error_hook_throttle {
crate::config::config().set_error_hook_throttle(max_per_second);
}
let mut appends: Vec<Box<dyn logforth::Append>> = Vec::new();
#[cfg(feature = "fastrace")]
let traces_on = !matches!(&traces, TracesChoice::Off);
if stdout {
let base = logforth::append::Stdout::default();
let stdout = match layout {
LayoutChoice::Text => base,
#[cfg(feature = "json")]
LayoutChoice::Json => base.with_layout(logforth_layout_json::JsonLayout::default()),
#[cfg(not(feature = "json"))]
LayoutChoice::Json => {
log::warn!(
target: crate::log_targets::DEPLOY,
"LayoutChoice::Json requested but cargo feature `json` is not compiled in — using the text layout"
);
base
}
#[cfg(feature = "layout-logfmt")]
LayoutChoice::Logfmt => {
base.with_layout(logforth_layout_logfmt::LogfmtLayout::default())
}
#[cfg(not(feature = "layout-logfmt"))]
LayoutChoice::Logfmt => {
log::warn!(
target: crate::log_targets::DEPLOY,
"LayoutChoice::Logfmt requested but cargo feature `layout-logfmt` is not compiled in — using the text layout"
);
base
}
#[cfg(feature = "layout-gcl")]
LayoutChoice::Gcl => base.with_layout(
logforth_layout_google_cloud_logging::GoogleCloudLoggingLayout::default(),
),
#[cfg(not(feature = "layout-gcl"))]
LayoutChoice::Gcl => {
log::warn!(
target: crate::log_targets::DEPLOY,
"LayoutChoice::Gcl requested but cargo feature `layout-gcl` is not compiled in — using the text layout"
);
base
}
};
#[cfg(feature = "log-async")]
appends.push(trap(
maybe_async("fast-observe-log-stdout", stdout, async_append),
"stdout",
));
#[cfg(not(feature = "log-async"))]
appends.push(trap(stdout, "stdout"));
}
#[cfg(feature = "fastrace")]
if traces_on {
appends.push(trap(
logforth_append_fastrace::FastraceEvent::default(),
"fastrace-events",
));
}
#[cfg(all(feature = "web", target_arch = "wasm32", target_os = "unknown"))]
appends.push(trap(crate::profiling::web::WebConsoleAppend, "web-console"));
#[cfg(feature = "file")]
if file_from_env && let Some(file) = file_appender() {
#[cfg(feature = "log-async")]
appends.push(trap(
maybe_async("fast-observe-log-file", file, async_append),
"file",
));
#[cfg(not(feature = "log-async"))]
appends.push(trap(file, "file"));
}
#[cfg(all(feature = "log-syslog", unix))]
if syslog {
match logforth_append_syslog::SyslogBuilder::unix("/dev/log") {
Ok(builder) => appends.push(trap(builder.build(), "syslog")),
Err(e) => log::warn!(
target: crate::log_targets::DEPLOY,
"failed to connect syslog socket /dev/log: {e} — skipping the syslog appender"
),
}
}
#[cfg(not(all(feature = "log-syslog", unix)))]
if syslog {
missing_feature_warn!("syslog", "log-syslog", "skipping the syslog appender");
}
#[cfg(all(feature = "log-journald", unix))]
if journald {
match logforth_append_journald::Journald::new() {
Ok(journald) => appends.push(trap(journald, "journald")),
Err(e) => log::warn!(
target: crate::log_targets::DEPLOY,
"journald unavailable: {e} — skipping the journald appender"
),
}
}
#[cfg(not(all(feature = "log-journald", unix)))]
if journald {
missing_feature_warn!("journald", "log-journald", "skipping the journald appender");
}
#[cfg(not(feature = "log-async"))]
if async_append {
missing_feature_warn!("async_append", "log-async", "appenders stay synchronous");
}
#[cfg(not(feature = "diag-task-local"))]
if task_local_diagnostic {
missing_feature_warn!(
"task_local_diagnostic",
"diag-task-local",
"skipping the diagnostic"
);
}
#[cfg(not(feature = "filter-rustlog"))]
if rust_log_filter {
missing_feature_warn!("rust_log_filter", "filter-rustlog", "skipping the filter");
}
let split_level = stderr_from.map(to_logforth_level);
let mut builder = logforth::starter_log::builder();
let common = |d: logforth::core::DispatchBuilder<false>| {
let d = d.diagnostic(logforth::diagnostic::ThreadLocalDiagnostic::default());
#[cfg(feature = "fastrace")]
let d = if traces_on {
d.diagnostic(logforth_diagnostic_fastrace::FastraceDiagnostic::default())
} else {
d
};
#[cfg(feature = "diag-task-local")]
let d = if task_local_diagnostic {
d.diagnostic(logforth_diagnostic_task_local::TaskLocalDiagnostic::default())
} else {
d
};
let d = if static_diag.is_empty() {
d
} else {
d.diagnostic(logforth::diagnostic::StaticDiagnostic::new(
static_diag.iter().cloned().collect(),
))
};
#[cfg(feature = "filter-rustlog")]
let d = if rust_log_filter {
d.filter(build_rust_log_filter())
} else {
d
};
d
};
let mut appends = appends.into_iter();
if let Some(first) = appends.next() {
let main = |d: logforth::core::DispatchBuilder<false>| {
let d = match split_level {
Some(lv) => d.filter(logforth::record::LevelFilter::MoreVerbose(lv)),
None => d,
};
let d = common(d);
let d = d.append(first);
appends.fold(d, logforth::core::DispatchBuilder::append)
};
builder = builder.dispatch(main);
if let Some(lv) = split_level {
builder = builder.dispatch(|d| {
let d = d.filter(logforth::record::LevelFilter::MoreSevereEqual(lv));
let d = common(d);
d.append(trap(logforth::append::Stderr::default(), "stderr-split"))
});
}
} else if split_level.is_some() && !static_diag.is_empty() {
builder = builder.dispatch(|d| {
let d = common(d);
d.append(trap(logforth::append::Stderr::default(), "stderr-split"))
});
}
builder
.try_apply()
.map_err(|_| InitError::AlreadyInitialized)?;
log::set_max_level(resolve_level(level));
#[cfg(feature = "fastrace")]
if traces_on {
match traces {
TracesChoice::Console | TracesChoice::Off => fastrace::set_reporter(
fastrace::collector::ConsoleReporter,
fastrace::collector::Config::default(),
),
TracesChoice::ConsoleWith(config) => {
fastrace::set_reporter(fastrace::collector::ConsoleReporter, config);
}
TracesChoice::Custom(reporter, config) => {
fastrace::set_reporter(ReporterAdapter(reporter), config);
}
}
}
#[cfg(all(
feature = "web",
feature = "fastrace",
target_arch = "wasm32",
target_os = "unknown"
))]
crate::profiling::web::install_unload_flush();
if panic_hook {
install_panic_hook();
}
#[cfg(all(feature = "flush-on-exit", not(target_family = "wasm")))]
if flush_on_exit {
install_exit_flush();
}
Ok(InitGuard { _private: () })
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(default, deny_unknown_fields))]
pub struct DeploymentConfig {
pub level: Option<String>,
pub stdout: Option<bool>,
pub layout: Option<String>,
pub file_from_env: Option<bool>,
pub backends: Option<String>,
pub error_hook_throttle: Option<u32>,
pub traces: Option<String>,
pub panic_hook: Option<bool>,
pub flush_on_exit: Option<bool>,
pub syslog: Option<bool>,
pub journald: Option<bool>,
pub async_append: Option<bool>,
pub task_local_diagnostic: Option<bool>,
pub rust_log_filter: Option<bool>,
}
impl DeploymentConfig {
pub fn apply<S: deployment_builder::State>(
self,
builder: DeploymentBuilder<S>,
) -> Result<Deployment, Vec<ConfigError>> {
let Self {
level,
stdout,
layout,
file_from_env,
backends,
error_hook_throttle,
traces,
panic_hook,
flush_on_exit,
syslog,
journald,
async_append,
task_local_diagnostic,
rust_log_filter,
} = self;
let mut errors = Vec::new();
let level = level.and_then(|value| {
if let Ok(parsed) = value.trim().parse::<log::LevelFilter>() {
Some(parsed)
} else {
errors.push(ConfigError {
field: "level",
value,
reason: "expected off|error|warn|info|debug|trace",
});
None
}
});
let layout = layout.and_then(|value| {
if let Ok(layout) = value.trim().to_ascii_lowercase().parse::<LayoutChoice>() {
Some(layout)
} else {
errors.push(ConfigError {
field: "layout",
value,
reason: "expected text|json|logfmt|gcl",
});
None
}
});
let backends = backends.and_then(|value| {
if let Some(parsed) = crate::config::Backends::from_env_value(&value) {
Some(parsed)
} else {
errors.push(ConfigError {
field: "backends",
value,
reason: "expected comma-separated off|instant|fastrace|web|puffin|tracy|superluminal|tracing (`off` alone)",
});
None
}
});
let traces = traces.and_then(|value| {
if let Ok(traces) = value.trim().to_ascii_lowercase().parse::<TracesChoice>() {
Some(traces)
} else {
errors.push(ConfigError {
field: "traces",
value,
reason: "expected console|off",
});
None
}
});
if !errors.is_empty() {
return Err(errors);
}
let base = builder.build();
Ok(Deployment {
level: level.or(base.level),
stdout: stdout.unwrap_or(base.stdout),
layout: layout.unwrap_or(base.layout),
file_from_env: file_from_env.unwrap_or(base.file_from_env),
backends: backends.or(base.backends),
error_hook_throttle: error_hook_throttle.or(base.error_hook_throttle),
traces: traces.unwrap_or(base.traces),
panic_hook: panic_hook.unwrap_or(base.panic_hook),
flush_on_exit: flush_on_exit.unwrap_or(base.flush_on_exit),
syslog: syslog.unwrap_or(base.syslog),
journald: journald.unwrap_or(base.journald),
async_append: async_append.unwrap_or(base.async_append),
task_local_diagnostic: task_local_diagnostic.unwrap_or(base.task_local_diagnostic),
rust_log_filter: rust_log_filter.unwrap_or(base.rust_log_filter),
stderr_from: base.stderr_from,
static_diag: base.static_diag,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigError {
pub field: &'static str,
pub value: String,
pub reason: &'static str,
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let Self {
field,
value,
reason,
} = self;
write!(
f,
"invalid observe config field `{field}` = {value:?} — {reason}"
)
}
}
impl std::error::Error for ConfigError {}
#[derive(Debug)]
struct PanicError {
payload: String,
}
impl std::fmt::Display for PanicError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "panic: {}", self.payload)
}
}
impl std::error::Error for PanicError {
fn provide<'a>(&'a self, request: &mut core::error::Request<'a>) {
request.provide_value(crate::errors::CategoryTag(crate::ErrorCategory::Fatal));
}
}
fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let payload = crate::exn::payload_str(info.payload())
.unwrap_or("<non-string payload>")
.to_string();
let location = info.location().map_or_else(
|| "<unknown>".to_string(),
|l| format!("{}:{}", l.file(), l.line()),
);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _fault = crate::exn::Fault::new(PanicError { payload })
.attach_key("panic_location", location);
}));
previous(info);
}));
}
#[cfg(all(feature = "flush-on-exit", not(target_family = "wasm")))]
extern "C" fn fastrace_flush_trampoline() {
fastrace::flush();
}
#[cfg(all(feature = "flush-on-exit", not(target_family = "wasm")))]
#[allow(
unsafe_code,
function_casts_as_integer,
reason = "libc::atexit/libc::signal FFI contract: sighandler_t is a usize alias"
)]
fn install_exit_flush() {
unsafe {
if libc::atexit(fastrace_flush_trampoline) != 0 {
log::warn!(
target: crate::log_targets::DEPLOY,
"libc::atexit registration failed — exit flush will not run on normal exit"
);
}
libc::signal(
libc::SIGTERM,
fastrace_flush_trampoline as libc::sighandler_t,
);
libc::signal(
libc::SIGHUP,
fastrace_flush_trampoline as libc::sighandler_t,
);
}
}
fn resolve_level(explicit: Option<log::LevelFilter>) -> log::LevelFilter {
if let Some(level) = explicit {
return level;
}
for var in [crate::env_vars::OBSERVE_LOG, crate::env_vars::RUST_LOG] {
if let Ok(value) = std::env::var(var) {
match value.trim().parse::<log::LevelFilter>() {
Ok(level) => return level,
Err(_) => log::warn!(
target: crate::log_targets::DEPLOY,
"invalid {var}={value:?}; expected off|error|warn|info|debug|trace — falling through"
),
}
}
}
log::LevelFilter::Info
}
fn to_logforth_level(level: log::Level) -> logforth::record::Level {
match level {
log::Level::Error => logforth::record::Level::Error,
log::Level::Warn => logforth::record::Level::Warn,
log::Level::Info => logforth::record::Level::Info,
log::Level::Debug => logforth::record::Level::Debug,
log::Level::Trace => logforth::record::Level::Trace,
}
}
fn trap(
append: impl Into<Box<dyn logforth::Append>>,
name: &'static str,
) -> Box<dyn logforth::Append> {
Box::new(TrapAppender::new(append.into(), name))
}
#[derive(Debug)]
struct TrapAppender {
inner: Box<dyn logforth::Append>,
name: &'static str,
reported: std::sync::OnceLock<()>,
}
impl TrapAppender {
fn new(inner: Box<dyn logforth::Append>, name: &'static str) -> Self {
Self {
inner,
name,
reported: std::sync::OnceLock::new(),
}
}
fn report(&self, err: &logforth::Error) {
let _: &() = self.reported.get_or_init(|| {
log::error!(
target: crate::log_targets::DEPLOY,
appender = self.name;
"log appender failed: {err} — records to this destination are being dropped",
);
#[cfg(feature = "fastrace")]
fastrace::local::LocalSpan::add_event(
fastrace::Event::new("log_appender_failure").with_properties(|| {
use std::borrow::Cow;
[
(Cow::Borrowed("appender"), Cow::Borrowed(self.name)),
(Cow::Borrowed("error"), Cow::Owned(err.to_string())),
]
}),
);
});
}
}
impl logforth::Append for TrapAppender {
fn append(
&self,
record: &logforth::record::Record,
diags: &[Box<dyn logforth::diagnostic::Diagnostic>],
) -> Result<(), logforth::Error> {
match self.inner.append(record, diags) {
Ok(()) => Ok(()),
Err(err) => {
self.report(&err);
Ok(())
}
}
}
fn flush(&self) -> Result<(), logforth::Error> {
match self.inner.flush() {
Ok(()) => Ok(()),
Err(err) => {
self.report(&err);
Ok(())
}
}
}
}
#[cfg(feature = "fastrace")]
struct ReporterAdapter(Box<dyn fastrace::collector::Reporter>);
#[cfg(feature = "fastrace")]
impl fastrace::collector::Reporter for ReporterAdapter {
fn report(&mut self, spans: Vec<fastrace::collector::SpanRecord>) {
self.0.report(spans);
}
}
#[cfg(feature = "log-async")]
fn maybe_async(
thread_name: &'static str,
append: impl Into<Box<dyn logforth::Append>>,
enabled: bool,
) -> Box<dyn logforth::Append> {
if enabled {
Box::new(
logforth_append_async::AsyncBuilder::new(thread_name)
.append(append)
.build(),
)
} else {
append.into()
}
}
#[cfg(feature = "filter-rustlog")]
fn build_rust_log_filter() -> logforth_filter_rustlog::RustLogFilter {
use logforth_filter_rustlog::RustLogFilterBuilder;
match std::env::var(crate::env_vars::OBSERVE_LOG) {
Ok(spec) => RustLogFilterBuilder::from_spec(spec).build(),
Err(_) => RustLogFilterBuilder::from_default_env_or("info").build(),
}
}
#[cfg(feature = "file")]
fn file_appender() -> Option<logforth_append_file::File> {
let dir = std::env::var(crate::env_vars::OBSERVE_LOG_DIR).ok()?;
match logforth_append_file::FileBuilder::new(dir, "app.log").build() {
Ok(file) => Some(file),
Err(e) => {
log::error!(target: crate::log_targets::DEPLOY, "failed to build file appender: {e}");
None
}
}
}
#[must_use = "dropping the guard flushes fastrace — keep it alive until shutdown"]
pub struct InitGuard {
_private: (),
}
impl Drop for InitGuard {
fn drop(&mut self) {
#[cfg(feature = "fastrace")]
fastrace::flush();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InitError {
AlreadyInitialized,
}
impl std::fmt::Display for InitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AlreadyInitialized => f.write_str(
"observability already initialized: the global `log` logger is already set",
),
}
}
}
impl std::error::Error for InitError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_setters_build_defaults() {
let d = observe().build();
assert!(d.level.is_none());
assert!(d.stdout);
assert!(matches!(d.layout, LayoutChoice::Text));
assert!(!d.file_from_env);
assert!(d.backends.is_none());
assert!(d.error_hook_throttle.is_none());
assert!(matches!(d.traces, TracesChoice::Console));
assert!(d.panic_hook);
assert!(d.flush_on_exit);
}
#[test]
fn setters_chain_in_any_combination() {
let d = observe()
.level(log::LevelFilter::Debug)
.stdout(false)
.layout(LayoutChoice::Json)
.file_from_env(true)
.backends(crate::config::Backends::OFF)
.error_hook_throttle(10)
.traces(TracesChoice::Off)
.panic_hook(false)
.flush_on_exit(false)
.syslog(true)
.journald(true)
.async_append(true)
.task_local_diagnostic(true)
.rust_log_filter(true)
.build();
assert_eq!(d.level, Some(log::LevelFilter::Debug));
assert!(!d.stdout);
assert!(matches!(d.layout, LayoutChoice::Json));
assert!(d.file_from_env);
assert_eq!(d.backends, Some(crate::config::Backends::OFF));
assert_eq!(d.error_hook_throttle, Some(10));
assert!(matches!(d.traces, TracesChoice::Off));
assert!(!d.panic_hook);
assert!(!d.flush_on_exit);
assert!(d.syslog);
assert!(d.journald);
assert!(d.async_append);
assert!(d.task_local_diagnostic);
assert!(d.rust_log_filter);
}
#[test]
fn toggles_default_off() {
let d = observe().build();
assert!(!d.syslog);
assert!(!d.journald);
assert!(!d.async_append);
assert!(!d.task_local_diagnostic);
assert!(!d.rust_log_filter);
}
#[test]
fn config_apply_sets_fields() {
let cfg = DeploymentConfig {
level: Some("debug".to_owned()),
stdout: Some(false),
layout: Some("json".to_owned()),
file_from_env: Some(true),
backends: Some("fastrace,tracy".to_owned()),
error_hook_throttle: Some(7),
traces: Some("off".to_owned()),
panic_hook: Some(false),
flush_on_exit: Some(false),
syslog: Some(true),
journald: Some(true),
async_append: Some(true),
task_local_diagnostic: Some(true),
rust_log_filter: Some(true),
};
let Ok(d) = cfg.apply(observe()) else {
unreachable!("all-Some config must apply")
};
assert_eq!(d.level, Some(log::LevelFilter::Debug));
assert!(!d.stdout);
assert!(matches!(d.layout, LayoutChoice::Json));
assert!(d.file_from_env);
assert_eq!(
d.backends,
Some(crate::config::Backends::FASTRACE | crate::config::Backends::TRACY)
);
assert_eq!(d.error_hook_throttle, Some(7));
assert!(matches!(d.traces, TracesChoice::Off));
assert!(!d.panic_hook);
assert!(!d.flush_on_exit);
assert!(d.syslog);
assert!(d.journald);
assert!(d.async_append);
assert!(d.task_local_diagnostic);
assert!(d.rust_log_filter);
}
#[test]
fn config_apply_collects_errors_and_leaves_defaults() {
let cfg = DeploymentConfig {
level: Some("bogus".to_owned()),
backends: Some("nope".to_owned()),
..DeploymentConfig::default()
};
let Err(errors) = cfg.apply(observe()) else {
unreachable!("bad values must not apply")
};
assert_eq!(errors.len(), 2, "errors collected, not first-wins");
assert!(
errors
.iter()
.any(|e| e.field == "level" && e.value == "bogus")
);
assert!(
errors
.iter()
.any(|e| e.field == "backends" && e.value == "nope")
);
let Ok(d) = DeploymentConfig::default().apply(observe()) else {
unreachable!("all-None config must apply")
};
assert!(d.level.is_none());
assert!(d.stdout);
assert!(matches!(d.layout, LayoutChoice::Text));
assert!(!d.file_from_env);
assert!(d.backends.is_none());
assert!(d.error_hook_throttle.is_none());
assert!(matches!(d.traces, TracesChoice::Console));
assert!(d.panic_hook);
assert!(d.flush_on_exit);
}
#[test]
fn config_apply_overlays_preset_builder() {
let builder = observe()
.level(log::LevelFilter::Error)
.error_hook_throttle(3);
let cfg = DeploymentConfig {
level: Some("debug".to_owned()),
..DeploymentConfig::default()
};
let Ok(d) = cfg.apply(builder) else {
unreachable!("valid config must apply")
};
assert_eq!(d.level, Some(log::LevelFilter::Debug), "Some wins");
assert_eq!(d.error_hook_throttle, Some(3), "None preserves the setter");
}
#[test]
fn from_config_matches_apply_on_observe() {
let cfg = DeploymentConfig {
level: Some("warn".to_owned()),
..DeploymentConfig::default()
};
let Ok(d) = Deployment::from_config(cfg) else {
unreachable!("valid config must apply")
};
assert_eq!(d.level, Some(log::LevelFilter::Warn));
}
#[test]
fn level_resolution_order() {
assert_eq!(
resolve_level(Some(log::LevelFilter::Warn)),
log::LevelFilter::Warn
);
if std::env::var_os(crate::env_vars::OBSERVE_LOG).is_none()
&& std::env::var_os(crate::env_vars::RUST_LOG).is_none()
{
assert_eq!(resolve_level(None), log::LevelFilter::Info);
}
}
}