use std::sync::OnceLock;
use crate::trace;
use crate::util::app_config_reader::AppConfigReader;
use crate::util::config_reader::{ConfigError, ConfigReader};
const CONFIG_FILE: &str = "classpath:/app-log-context.yaml";
const DEFAULT_TEMPLATE: &str = include_str!("../resources/default-log-context.yaml");
const FEATURE_FLAG: &str = "app.log.context";
const CONTEXT: &str = "context";
pub struct LogContextConfig {
enabled: bool,
tokens: Vec<(String, String)>,
constants: Vec<(String, String)>,
}
impl LogContextConfig {
pub fn instance() -> &'static LogContextConfig {
static INSTANCE: OnceLock<LogContextConfig> = OnceLock::new();
INSTANCE.get_or_init(Self::load_config_file)
}
fn load_config_file() -> LogContextConfig {
let config = AppConfigReader::get_instance();
if config.get_property_or(FEATURE_FLAG, "true") == "false" {
log::info!("Application log context disabled by {FEATURE_FLAG}=false");
return LogContextConfig::disabled();
}
match ConfigReader::load(CONFIG_FILE) {
Ok(reader) => LogContextConfig::from_reader(&reader),
Err(ConfigError::NotFound(_)) => {
match ConfigReader::from_yaml_text(DEFAULT_TEMPLATE) {
Ok(reader) => LogContextConfig::from_reader(&reader),
Err(e) => {
log::warn!("Built-in default-log-context.yaml invalid - {e}");
LogContextConfig::disabled()
}
}
}
Err(e) => {
log::error!("Unable to load {CONFIG_FILE} - {e}");
LogContextConfig::disabled()
}
}
}
fn disabled() -> Self {
LogContextConfig {
enabled: false,
tokens: Vec::new(),
constants: Vec::new(),
}
}
pub fn from_reader(reader: &ConfigReader) -> Self {
let mut tokens = Vec::new();
let mut constants = Vec::new();
let section: Vec<String> = match reader.get_map().get_element(CONTEXT) {
Some(crate::ConfigValue::Map(m)) => m.keys().cloned().collect(),
_ => {
log::warn!("Log context config has no '{CONTEXT}' section - feature disabled");
return LogContextConfig::disabled();
}
};
for output_key in section {
let Some(value) = reader.get_property(&format!("{CONTEXT}.{output_key}")) else {
continue;
};
if let Some(token_name) = value.strip_prefix('$').filter(|_| !value.starts_with("${")) {
if trace::RESERVED_KEYS.contains(&token_name) {
tokens.push((output_key, token_name.to_string()));
} else {
log::error!(
"Invalid log context token '{value}' for key '{output_key}' - allowed: {:?}",
trace::RESERVED_KEYS
);
}
} else {
constants.push((output_key, value));
}
}
let enabled = !tokens.is_empty() || !constants.is_empty();
if enabled {
if !tokens.iter().any(|(_, token)| token == "utc") {
let taken = |key: &str| {
tokens.iter().any(|(k, _)| k == key) || constants.iter().any(|(k, _)| k == key)
};
if !taken("timestamp") {
tokens.push(("timestamp".to_string(), "utc".to_string()));
} else if !taken("utc") {
tokens.push(("utc".to_string(), "utc".to_string()));
} else {
log::warn!(
"Log context template maps $utc to no key and both 'timestamp' and 'utc' \
are taken - no UTC timestamp is added to the context block"
);
}
}
log::info!(
"Application log context enabled with {} context key-value(s)",
tokens.len() + constants.len()
);
}
LogContextConfig {
enabled,
tokens,
constants,
}
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn render(
&self,
state: &trace::TraceState,
log_time: std::time::SystemTime,
) -> serde_json::Map<String, serde_json::Value> {
let mut out = serde_json::Map::new();
for (key, value) in &state.custom_log_keys {
if !value.is_null() {
out.insert(key.clone(), value.clone());
}
}
for (output_key, token_name) in &self.tokens {
if let Some(value) = state.token(token_name, log_time) {
out.insert(output_key.clone(), value);
}
}
for (output_key, constant) in &self.constants {
out.insert(
output_key.clone(),
serde_json::Value::String(constant.clone()),
);
}
out
}
}
#[derive(Clone, Copy, PartialEq)]
enum LogFormat {
Text,
Json,
Compact,
}
impl LogFormat {
fn resolve(name: &str) -> LogFormat {
match name.to_ascii_lowercase().as_str() {
"json" => LogFormat::Json,
"compact" => LogFormat::Compact,
_ => LogFormat::Text,
}
}
}
struct PlatformLogger {
format: LogFormat,
level: log::LevelFilter,
}
impl log::Log for PlatformLogger {
fn enabled(&self, metadata: &log::Metadata) -> bool {
metadata.level() <= self.level
}
fn log(&self, record: &log::Record) {
if !self.enabled(record.metadata()) {
return;
}
let now = std::time::SystemTime::now();
let time = trace::iso8601_utc(now);
if self.format == LogFormat::Text {
println!(
"{time} {:<5} [{}] {}",
record.level(),
record.module_path().unwrap_or("unknown"),
record.args()
);
return;
}
let mut line = serde_json::Map::new();
line.insert("time".into(), serde_json::Value::String(time));
line.insert(
"level".into(),
serde_json::Value::String(record.level().to_string()),
);
line.insert(
"source".into(),
serde_json::Value::String(format!(
"{}({}:{})",
record.module_path().unwrap_or("unknown"),
record.file().unwrap_or("?"),
record.line().unwrap_or(0)
)),
);
let message = record.args().to_string();
let message_value = if message.starts_with('{') {
serde_json::from_str::<serde_json::Value>(&message)
.unwrap_or(serde_json::Value::String(message))
} else {
serde_json::Value::String(message)
};
line.insert("message".into(), message_value);
let config = LogContextConfig::instance();
if config.is_enabled() {
let context = trace::with_current(|state| {
if state.zero_traced {
None
} else {
Some(config.render(state, now))
}
})
.flatten();
if let Some(context) = context {
if !context.is_empty() {
line.insert("context".into(), serde_json::Value::Object(context));
}
}
}
let line = serde_json::Value::Object(line);
match self.format {
LogFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&line).unwrap_or_else(|_| line.to_string())
),
_ => println!("{line}"),
}
}
fn flush(&self) {}
}
pub fn init() {
crate::util::overrides::load_runtime_args();
let config = AppConfigReader::get_instance();
let format = LogFormat::resolve(&config.get_property_or("log.format", "text"));
let level_text = std::env::var("RUST_LOG")
.ok()
.unwrap_or_else(|| config.get_property_or("log.level", "info"));
let level = match level_text.to_ascii_lowercase().as_str() {
"error" => log::LevelFilter::Error,
"warn" => log::LevelFilter::Warn,
"debug" => log::LevelFilter::Debug,
"trace" => log::LevelFilter::Trace,
"off" => log::LevelFilter::Off,
_ => log::LevelFilter::Info,
};
let context = LogContextConfig::instance();
if log::set_boxed_logger(Box::new(PlatformLogger { format, level })).is_ok() {
log::set_max_level(level);
if context.is_enabled() {
log::info!(
"Application log context enabled with {} context key-value(s)",
context.tokens.len() + context.constants.len()
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_resolution() {
assert!(matches!(LogFormat::resolve("json"), LogFormat::Json));
assert!(matches!(LogFormat::resolve("JSON"), LogFormat::Json));
assert!(matches!(LogFormat::resolve("compact"), LogFormat::Compact));
assert!(matches!(LogFormat::resolve("text"), LogFormat::Text));
assert!(matches!(LogFormat::resolve("unknown"), LogFormat::Text)); }
#[test]
fn log_context_is_on_by_default_overridable_and_can_opt_out() {
let config = LogContextConfig::load_config_file();
assert!(config.is_enabled(), "log context must be ON by default");
let token_keys: Vec<&str> = config.tokens.iter().map(|(k, _)| k.as_str()).collect();
for expected in [
"cid",
"trace_id",
"trace_path",
"span_id",
"parent_span_id",
"service",
"timestamp",
] {
assert!(
token_keys.contains(&expected),
"built-in template must carry '{expected}'"
);
}
assert!(
config.constants.is_empty(),
"built-in default has no constants"
);
crate::util::overrides::set(FEATURE_FLAG, "false");
let config = LogContextConfig::load_config_file();
crate::util::overrides::clear(FEATURE_FLAG);
assert!(!config.is_enabled(), "app.log.context=false must opt out");
let dir = std::env::temp_dir().join(format!("pc-logctx-default-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("app-log-context.yaml"),
"context:\n onlyKey: $service\n",
)
.unwrap();
crate::util::resources::prepend_resource_root(&dir);
let config = LogContextConfig::load_config_file();
assert!(config.is_enabled());
assert_eq!(
config.tokens,
vec![
("onlyKey".to_string(), "service".to_string()),
("timestamp".to_string(), "utc".to_string()),
],
"the application template must replace the built-in default entirely"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn utc_timestamp_is_supplied_when_the_template_omits_it() {
let reader = |text: &str| ConfigReader::from_yaml_text(text).expect("yaml");
let inserted = LogContextConfig::from_reader(&reader("context:\n svc: $service\n"));
assert!(inserted
.tokens
.contains(&("timestamp".to_string(), "utc".to_string())));
let explicit =
LogContextConfig::from_reader(&reader("context:\n when: $utc\n svc: $service\n"));
assert_eq!(
1,
explicit.tokens.iter().filter(|(_, t)| t == "utc").count()
);
assert!(explicit
.tokens
.contains(&("when".to_string(), "utc".to_string())));
let fallback = LogContextConfig::from_reader(&reader("context:\n timestamp: $service\n"));
assert!(fallback
.tokens
.contains(&("utc".to_string(), "utc".to_string())));
let both_taken = LogContextConfig::from_reader(&reader(
"context:\n timestamp: $service\n utc: hello\n",
));
assert!(!both_taken.tokens.iter().any(|(_, t)| t == "utc"));
}
#[test]
fn template_keys_win_over_developer_keys() {
let config = LogContextConfig::from_reader(
&ConfigReader::from_yaml_text("context:\n service: $service\n env: dev\n")
.expect("yaml"),
);
let mut state = trace::TraceState::new("greeting.demo", "t1", "GET /x", None, None);
state
.custom_log_keys
.insert("service".to_string(), serde_json::json!("shadow"));
state
.custom_log_keys
.insert("env".to_string(), serde_json::json!("shadow"));
state
.custom_log_keys
.insert("user".to_string(), serde_json::json!("eric"));
let out = config.render(&state, std::time::SystemTime::now());
assert_eq!("greeting.demo", out["service"]);
assert_eq!("dev", out["env"]);
assert_eq!("eric", out["user"]);
assert!(out.contains_key("timestamp"), "the automatic UTC timestamp");
}
}