use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::{EnvFilter, fmt};
use crate::values::Var;
const LOG: &str = "ISOTHRUM_LOG";
const LOG_FORMAT: &str = "ISOTHRUM_LOG_FORMAT";
type Subscriber = Box<dyn tracing::Subscriber + Send + Sync>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Format {
Text,
Json,
}
pub(crate) type Error = Box<dyn std::error::Error + Send + Sync>;
pub(crate) fn from_env(var: &Var<'_>) -> Result<(), Error> {
let Some(filter) = text(var, LOG)? else {
return Ok(());
};
let format = text(var, LOG_FORMAT)?;
to_stderr(&filter, format.as_deref())
}
fn text(var: &Var<'_>, name: &str) -> Result<Option<String>, Error> {
var(name)
.map(|value| value.into_string().map_err(|_| format!("{name} isn't UTF-8: no tracing").into()))
.transpose()
}
fn to_stderr(filter: &str, format: Option<&str>) -> Result<(), Error> {
tracing::subscriber::set_global_default(subscriber(filter, format, std::io::stderr)?)?;
Ok(())
}
fn subscriber<W>(filter: &str, format: Option<&str>, writer: W) -> Result<Subscriber, Error>
where
W: for<'w> MakeWriter<'w> + Send + Sync + 'static,
{
let filter = EnvFilter::try_new(filter).map_err(|e| format!("{LOG}={filter}: {e}"))?;
let builder = fmt().with_env_filter(filter).with_writer(writer);
Ok(match parse_format(format)? {
Format::Text => Box::new(builder.finish()),
Format::Json => Box::new(builder.json().finish()),
})
}
fn parse_format(format: Option<&str>) -> Result<Format, Error> {
match format.map(str::trim).filter(|f| !f.is_empty()) {
None => Ok(Format::Text),
Some(f) if f.eq_ignore_ascii_case("text") => Ok(Format::Text),
Some(f) if f.eq_ignore_ascii_case("json") => Ok(Format::Json),
Some(other) => Err(format!("ISOTHRUM_LOG_FORMAT={other}: use `text` or `json`").into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::Buf;
impl<'w> MakeWriter<'w> for Buf {
type Writer = Buf;
fn make_writer(&'w self) -> Buf {
self.clone()
}
}
fn traced(filter: &str, format: Option<&str>) -> String {
let lines = Buf::default();
let subscriber = subscriber(filter, format, lines.clone()).unwrap();
tracing::subscriber::with_default(subscriber, || {
tracing::debug!(target: "isothrum_runner::process", program = "docker", "starting a process");
tracing::debug!(target: "other", "noise");
});
lines.take()
}
#[test]
fn a_directive_filters_by_crate_and_a_level_still_works() {
let runner_only = traced("isothrum_runner=debug", None);
assert!(runner_only.contains("starting a process") && !runner_only.contains("noise"), "{runner_only}");
let everything = traced("debug", Some("text"));
assert!(everything.contains("starting a process") && everything.contains("noise"), "{everything}");
assert_eq!(traced("info", None), "");
}
#[test]
fn the_json_format_writes_an_object_per_line() {
let text = traced("isothrum_runner=debug", Some("JSON"));
let line: serde_json::Value = serde_json::from_str(text.lines().next().unwrap()).unwrap();
assert_eq!(line["fields"]["message"], "starting a process");
assert_eq!((line["fields"]["program"].as_str(), line["level"].as_str()), (Some("docker"), Some("DEBUG")));
}
#[cfg(unix)]
#[test]
fn a_log_variable_that_isnt_utf8_is_refused_by_name() {
use std::os::unix::ffi::OsStringExt;
for name in [LOG, LOG_FORMAT] {
let var = |asked: &str| match asked {
_ if asked == name => Some(std::ffi::OsString::from_vec(b"bad\xff".to_vec())),
LOG => Some("debug".into()),
_ => None,
};
let refused = from_env(&var).unwrap_err().to_string();
assert!(refused.contains(&format!("{name} isn't UTF-8")), "{refused}");
}
}
#[test]
fn an_unknown_format_or_filter_is_refused_naming_its_own_variable() {
let format = subscriber("debug", Some("yaml"), Buf::default()).err().unwrap().to_string();
assert_eq!(format, "ISOTHRUM_LOG_FORMAT=yaml: use `text` or `json`");
let filter = subscriber("isothrum_runner=loud", None, Buf::default()).err().unwrap().to_string();
assert!(filter.starts_with("ISOTHRUM_LOG=isothrum_runner=loud: "), "{filter}");
}
}