zlog: Support configuring log levels with env var (#28544)

Reimplemented logic from `env_logger` to parse log configuration from
environment variables.

Had to re-implement instead of using `env_filter` crate that
`env_logger` uses, as it does not export the information required to
integrate it.


Release Notes:

- N/A *or* Added/Fixed/Improved ...
This commit is contained in:
Ben Kunkle 2025-04-10 18:00:44 -04:00 committed by GitHub
parent 44cb8e582b
commit 66dd6726df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 277 additions and 16 deletions

2
Cargo.lock generated
View file

@ -18287,6 +18287,7 @@ dependencies = [
"workspace-hack",
"zed_actions",
"zeta",
"zlog",
"zlog_settings",
]
@ -18599,6 +18600,7 @@ dependencies = [
name = "zlog"
version = "0.1.0"
dependencies = [
"anyhow",
"log",
"workspace-hack",
]

View file

@ -138,6 +138,7 @@ welcome.workspace = true
workspace.workspace = true
zed_actions.workspace = true
zeta.workspace = true
zlog.workspace = true
zlog_settings.workspace = true
workspace-hack.workspace = true

View file

@ -195,6 +195,7 @@ fn main() {
return;
}
zlog::init_from_env();
if stdout_is_a_pty() {
init_stdout_logger();
} else {

View file

@ -17,3 +17,4 @@ default = []
[dependencies]
log.workspace = true
workspace-hack.workspace = true
anyhow.workspace = true

View file

@ -0,0 +1,126 @@
use anyhow::{Result, anyhow};
pub struct EnvFilter {
pub level_global: Option<log::LevelFilter>,
pub directive_names: Vec<String>,
pub directive_levels: Vec<log::LevelFilter>,
}
pub fn parse(filter: &str) -> Result<EnvFilter> {
let mut max_level = None;
let mut directive_names = Vec::new();
let mut directive_levels = Vec::new();
for directive in filter.split(',') {
match directive.split_once('=') {
Some((name, level)) => {
if level.contains('=') {
return Err(anyhow!("Invalid directive: {}", directive));
}
let level = parse_level(level.trim())?;
directive_names.push(name.to_string());
directive_levels.push(level);
}
None => {
let Ok(level) = parse_level(directive.trim()) else {
directive_names.push(directive.trim().to_string());
directive_levels.push(log::LevelFilter::max() /* Enable all levels */);
continue;
};
if max_level.is_some() {
return Err(anyhow!("Cannot set multiple max levels"));
}
max_level.replace(level);
}
};
}
Ok(EnvFilter {
level_global: max_level,
directive_names,
directive_levels,
})
}
fn parse_level(level: &str) -> Result<log::LevelFilter> {
if level.eq_ignore_ascii_case("TRACE") {
return Ok(log::LevelFilter::Trace);
}
if level.eq_ignore_ascii_case("DEBUG") {
return Ok(log::LevelFilter::Debug);
}
if level.eq_ignore_ascii_case("INFO") {
return Ok(log::LevelFilter::Info);
}
if level.eq_ignore_ascii_case("WARN") {
return Ok(log::LevelFilter::Warn);
}
if level.eq_ignore_ascii_case("ERROR") {
return Ok(log::LevelFilter::Error);
}
if level.eq_ignore_ascii_case("OFF") || level.eq_ignore_ascii_case("NONE") {
return Ok(log::LevelFilter::Off);
}
Err(anyhow!("Invalid level: {}", level))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn global_level() {
let input = "info";
let filter = parse(input).unwrap();
assert_eq!(filter.level_global.unwrap(), log::LevelFilter::Info);
assert!(filter.directive_names.is_empty());
assert!(filter.directive_levels.is_empty());
}
#[test]
fn directive_level() {
let input = "my_module=debug";
let filter = parse(input).unwrap();
assert_eq!(filter.level_global, None);
assert_eq!(filter.directive_names, vec!["my_module".to_string()]);
assert_eq!(filter.directive_levels, vec![log::LevelFilter::Debug]);
}
#[test]
fn global_level_and_directive_level() {
let input = "info,my_module=debug";
let filter = parse(input).unwrap();
assert_eq!(filter.level_global.unwrap(), log::LevelFilter::Info);
assert_eq!(filter.directive_names, vec!["my_module".to_string()]);
assert_eq!(filter.directive_levels, vec![log::LevelFilter::Debug]);
}
#[test]
fn global_level_and_bare_module() {
let input = "info,my_module";
let filter = parse(input).unwrap();
assert_eq!(filter.level_global.unwrap(), log::LevelFilter::Info);
assert_eq!(filter.directive_names, vec!["my_module".to_string()]);
assert_eq!(filter.directive_levels, vec![log::LevelFilter::max()]);
}
#[test]
fn err_when_multiple_max_levels() {
let input = "info,warn";
let result = parse(input);
assert!(result.is_err());
}
#[test]
fn err_when_invalid_level() {
let input = "my_module=foobar";
let result = parse(input);
assert!(result.is_err());
}
}

View file

@ -1,8 +1,26 @@
//! # logger
pub use log as log_impl;
mod env_config;
pub const SCOPE_DEPTH_MAX: usize = 4;
pub fn init_from_env() {
let Ok(env_config) = std::env::var("ZED_LOG").or_else(|_| std::env::var("RUST_LOG")) else {
return;
};
match env_config::parse(&env_config) {
Ok(filter) => {
scope_map::init_env_filter(filter);
scope_map::refresh();
// TODO: set max level once removing `env_logger` and `simple_log` crates
}
Err(err) => {
eprintln!("Failed to parse log filter: {}", err);
}
}
}
/// because we are currently just wrapping the `log` crate in `zlog`,
/// we need to work around the fact that the `log` crate only provides a
/// single global level filter. In order to have more precise control until
@ -275,17 +293,23 @@ pub mod scope_map {
collections::{HashMap, VecDeque},
hash::{DefaultHasher, Hasher},
sync::{
RwLock,
OnceLock, RwLock,
atomic::{AtomicU64, Ordering},
},
usize,
};
use super::*;
static ENV_FILTER: OnceLock<env_config::EnvFilter> = OnceLock::new();
static SCOPE_MAP: RwLock<Option<ScopeMap>> = RwLock::new(None);
static SCOPE_MAP_HASH: AtomicU64 = AtomicU64::new(0);
pub fn init_env_filter(filter: env_config::EnvFilter) {
if ENV_FILTER.set(filter).is_err() {
panic!("Environment filter cannot be initialized twice");
}
}
pub fn is_scope_enabled(scope: &Scope, level: log_impl::Level) -> (bool, log_impl::Level) {
let level_min = min_printed_log_level(level);
if level <= level_min {
@ -340,13 +364,18 @@ pub mod scope_map {
return hasher.finish();
}
pub fn refresh(settings: &HashMap<String, String>) {
pub(crate) fn refresh() {
refresh_from_settings(&HashMap::default());
}
pub fn refresh_from_settings(settings: &HashMap<String, String>) {
let hash_old = SCOPE_MAP_HASH.load(Ordering::Acquire);
let hash_new = hash_scope_map_settings(settings);
if hash_old == hash_new && hash_old != 0 {
return;
}
let map_new = ScopeMap::new_from_settings(settings);
let env_config = ENV_FILTER.get();
let map_new = ScopeMap::new_from_settings_and_env(settings, env_config);
if let Ok(_) = SCOPE_MAP_HASH.compare_exchange(
hash_old,
@ -430,15 +459,41 @@ pub mod scope_map {
}
impl ScopeMap {
pub fn new_from_settings(items_input_map: &HashMap<String, String>) -> Self {
let mut items = items_input_map
.into_iter()
.filter_map(|(scope_str, level_str)| {
let scope = scope_alloc_from_scope_str(&scope_str)?;
let level = level_from_level_str(&level_str)?;
return Some((scope, level));
})
.collect::<Vec<_>>();
pub fn new_from_settings_and_env(
items_input_map: &HashMap<String, String>,
env_config: Option<&env_config::EnvFilter>,
) -> Self {
let mut items = Vec::with_capacity(
items_input_map.len() + env_config.map_or(0, |c| c.directive_names.len()),
);
if let Some(env_filter) = env_config {
// TODO: parse on load instead of every reload
items.extend(
env_filter
.directive_names
.iter()
.zip(env_filter.directive_levels.iter())
.filter_map(|(scope, level_filter)| {
if items_input_map.get(scope).is_some() {
return None;
}
let scope = scope_alloc_from_scope_str(scope)?;
// TODO: use level filters instead of scopes in scope map
let level = level_filter.to_level()?;
Some((scope, level))
}),
);
}
items.extend(
items_input_map
.into_iter()
.filter_map(|(scope_str, level_str)| {
let scope = scope_alloc_from_scope_str(&scope_str)?;
let level = level_from_level_str(&level_str)?;
return Some((scope, level));
}),
);
items.sort_by(|a, b| a.0.cmp(&b.0));
@ -491,7 +546,12 @@ pub mod scope_map {
let is_last = depth + 1 == SCOPE_DEPTH_MAX || !is_valid_scope;
let mut enabled = None;
if is_last {
assert_eq!(sub_items_start + 1, sub_items_end);
assert_eq!(
sub_items_start + 1,
sub_items_end,
"Expected one item: got: {:?}",
&items[items_range.clone()]
);
enabled = Some(items[sub_items_start].1);
} else {
let entry_index = this.entries.len();
@ -562,6 +622,8 @@ pub mod scope_map {
#[cfg(test)]
mod tests {
use crate::private::scope_new;
use super::*;
fn scope_map_from_keys(kv: &[(&str, &str)]) -> ScopeMap {
@ -569,7 +631,7 @@ pub mod scope_map {
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
ScopeMap::new_from_settings(&hash_map)
ScopeMap::new_from_settings_and_env(&hash_map, None)
}
#[test]
@ -692,6 +754,74 @@ pub mod scope_map {
EnabledStatus::Disabled
);
}
fn scope_map_from_keys_and_env(
kv: &[(&str, &str)],
env: &env_config::EnvFilter,
) -> ScopeMap {
let hash_map: HashMap<String, String> = kv
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
ScopeMap::new_from_settings_and_env(&hash_map, Some(env))
}
#[test]
fn test_initialization_with_env() {
let env_filter = env_config::parse("a.b=debug,u=error").unwrap();
let map = scope_map_from_keys_and_env(&[], &env_filter);
assert_eq!(map.root_count, 2);
assert_eq!(map.entries.len(), 3);
assert_eq!(
map.is_enabled(&scope_new(&["a"]), log_impl::Level::Debug),
EnabledStatus::NotConfigured
);
assert_eq!(
map.is_enabled(&scope_new(&["a", "b"]), log_impl::Level::Debug),
EnabledStatus::Enabled
);
assert_eq!(
map.is_enabled(&scope_new(&["a", "b", "c"]), log_impl::Level::Trace),
EnabledStatus::Disabled
);
let env_filter = env_config::parse("a.b=debug,e.f.g.h=trace,u=error").unwrap();
let map = scope_map_from_keys_and_env(
&[
("a.b.c.d", "trace"),
("e.f.g.h", "debug"),
("i.j.k.l", "info"),
("m.n.o.p", "warn"),
("q.r.s.t", "error"),
],
&env_filter,
);
assert_eq!(map.root_count, 6);
assert_eq!(map.entries.len(), 21);
assert_eq!(map.entries[0].scope, "a");
assert_eq!(map.entries[1].scope, "e");
assert_eq!(map.entries[2].scope, "i");
assert_eq!(map.entries[3].scope, "m");
assert_eq!(map.entries[4].scope, "q");
assert_eq!(map.entries[5].scope, "u");
assert_eq!(
map.is_enabled(&scope_new(&["a", "b", "c", "d"]), log_impl::Level::Trace),
EnabledStatus::Enabled
);
assert_eq!(
map.is_enabled(&scope_new(&["a", "b", "c"]), log_impl::Level::Trace),
EnabledStatus::Disabled
);
assert_eq!(
map.is_enabled(&scope_new(&["u", "v"]), log_impl::Level::Warn),
EnabledStatus::Disabled
);
// settings override env
assert_eq!(
map.is_enabled(&scope_new(&["e", "f", "g", "h"]), log_impl::Level::Trace),
EnabledStatus::Disabled,
);
}
}
}

View file

@ -10,7 +10,7 @@ pub fn init(cx: &mut App) {
cx.observe_global::<SettingsStore>(|cx| {
let zlog_settings = ZlogSettings::get_global(cx);
zlog::scope_map::refresh(&zlog_settings.scopes);
zlog::scope_map::refresh_from_settings(&zlog_settings.scopes);
})
.detach();
}