platform_core/logging.rs
1//
2// Copyright 2018-2026 Accenture Technology
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8// http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15//
16
17//! Structured logging with the **application log context** — Rust port of the
18//! Java `LogContextConfig` + `JsonLogger`/`CompactAppender` design
19//! (`org.platformlambda.core.logging`).
20//!
21//! Spans tell you the causal path; application logs tell you what happened
22//! inside each step. The log-context feature is **on by default**: the crate
23//! ships a built-in `default-log-context.yaml` (embedded at compile time)
24//! carrying the standard trace context, so every structured (JSON) log line
25//! emitted inside a traced function carries a `context` block — correlation
26//! id, trace/span ids, service name, and any business key-values added via
27//! `PostOffice::update_context` — with zero setup. An application replaces
28//! the template with its own **`app-log-context.yaml`** on the resource path,
29//! or opts out entirely with `app.log.context=false` (default `true`).
30//!
31//! The template maps an output key (your choice) to one of three forms:
32//! a reserved **`$token`** (`$cid`, `$traceId`, `$tracePath`, `$spanId`,
33//! `$parentSpanId`, `$service`, `$utc` — resolved live per log line), a
34//! **`${ENV:default}`** substitution (resolved once at load, via the standard
35//! `ConfigReader`), or a **literal**. A key that resolves to nothing is
36//! omitted, never printed as null.
37//!
38//! [`init`] installs the process logger with three formats (the log4j2
39//! appender-selection analog): the default **`text`** is a plain console line
40//! and — like Java's plain `Console` appender — is unaffected by the log
41//! context; **`json`** pretty-prints each record (Java `log4j2-json.xml`);
42//! **`compact`** emits single-line jsonl records, no CR/LF (Java
43//! `log4j2-compact.xml`). `-Dkey=value` runtime arguments (the JVM `-D`
44//! analog) are honored, so `-Dlog.format=json` switches at launch without
45//! editing configuration. Deliberate simplifications (doc'd): UTC timestamps,
46//! no thread id.
47
48use std::sync::OnceLock;
49
50use crate::trace;
51use crate::util::app_config_reader::AppConfigReader;
52use crate::util::config_reader::{ConfigError, ConfigReader};
53
54const CONFIG_FILE: &str = "classpath:/app-log-context.yaml";
55/// The built-in default template (Java `default-log-context.yaml`), shipped
56/// under a DISTINCT file name from the application override. Java keeps the
57/// two names apart because same-named classpath resources shadow in
58/// classloader order; this port embeds the default at compile time — the
59/// same defensive design, enforced by the compiler.
60const DEFAULT_TEMPLATE: &str = include_str!("../resources/default-log-context.yaml");
61/// Feature switch (Java `app.log.context`), default `true`.
62const FEATURE_FLAG: &str = "app.log.context";
63const CONTEXT: &str = "context";
64
65/// Parsed log-context template (Java `LogContextConfig`) — the application's
66/// `app-log-context.yaml` when present, otherwise the built-in default.
67pub struct LogContextConfig {
68 enabled: bool,
69 /// output key → reserved token name (without the `$`), resolved per line
70 tokens: Vec<(String, String)>,
71 /// output key → constant (env-resolved or literal), fixed at load
72 constants: Vec<(String, String)>,
73}
74
75impl LogContextConfig {
76 /// The lazily-loaded singleton; touching [`AppConfigReader`] first
77 /// guarantees `${ENV:default}` substitution works regardless of timing
78 /// (Java parity).
79 pub fn instance() -> &'static LogContextConfig {
80 static INSTANCE: OnceLock<LogContextConfig> = OnceLock::new();
81 INSTANCE.get_or_init(Self::load_config_file)
82 }
83
84 /// Resolve the template with the Java `LogContextConfig.loadConfigFile`
85 /// order: the `app.log.context` switch (default on) → the application's
86 /// own `app-log-context.yaml` (replaces the template entirely) → the
87 /// built-in default, so the feature is on out of the box.
88 fn load_config_file() -> LogContextConfig {
89 let config = AppConfigReader::get_instance();
90 if config.get_property_or(FEATURE_FLAG, "true") == "false" {
91 log::info!("Application log context disabled by {FEATURE_FLAG}=false");
92 return LogContextConfig::disabled();
93 }
94 match ConfigReader::load(CONFIG_FILE) {
95 Ok(reader) => LogContextConfig::from_reader(&reader),
96 Err(ConfigError::NotFound(_)) => {
97 // no application override — fall back to the built-in default
98 match ConfigReader::from_yaml_text(DEFAULT_TEMPLATE) {
99 Ok(reader) => LogContextConfig::from_reader(&reader),
100 Err(e) => {
101 log::warn!("Built-in default-log-context.yaml invalid - {e}");
102 LogContextConfig::disabled()
103 }
104 }
105 }
106 Err(e) => {
107 log::error!("Unable to load {CONFIG_FILE} - {e}");
108 LogContextConfig::disabled()
109 }
110 }
111 }
112
113 fn disabled() -> Self {
114 LogContextConfig {
115 enabled: false,
116 tokens: Vec::new(),
117 constants: Vec::new(),
118 }
119 }
120
121 /// Build a config from a loaded reader. Public so tests and tooling can
122 /// exercise the enabled/disabled paths deterministically (the Java
123 /// package-private constructor's analog).
124 pub fn from_reader(reader: &ConfigReader) -> Self {
125 let mut tokens = Vec::new();
126 let mut constants = Vec::new();
127 let section: Vec<String> = match reader.get_map().get_element(CONTEXT) {
128 Some(crate::ConfigValue::Map(m)) => m.keys().cloned().collect(),
129 _ => {
130 log::warn!("Log context config has no '{CONTEXT}' section - feature disabled");
131 return LogContextConfig::disabled();
132 }
133 };
134 for output_key in section {
135 // ConfigReader resolves ${ENV:default} on the leaf value; an unset
136 // ${VAR} with no default resolves to nothing and is dropped
137 let Some(value) = reader.get_property(&format!("{CONTEXT}.{output_key}")) else {
138 continue;
139 };
140 if let Some(token_name) = value.strip_prefix('$').filter(|_| !value.starts_with("${")) {
141 if trace::RESERVED_KEYS.contains(&token_name) {
142 tokens.push((output_key, token_name.to_string()));
143 } else {
144 // Java throws here; the Rust port stays advisory —
145 // report and skip (deliberate divergence)
146 log::error!(
147 "Invalid log context token '{value}' for key '{output_key}' - allowed: {:?}",
148 trace::RESERVED_KEYS
149 );
150 }
151 } else {
152 constants.push((output_key, value));
153 }
154 }
155 let enabled = !tokens.is_empty() || !constants.is_empty();
156 if enabled {
157 // Java 4.12.13: the context block always carries a machine-parseable
158 // UTC time - the record's top-level `time` is what the operator's
159 // zone renders, and log-to-trace correlation resolves on a time
160 // window, so a line parsed in the wrong zone can be correctly
161 // correlated and still invisible on its trace. When the template
162 // maps `$utc` to no key, insert it as `timestamp` (falling back to
163 // `utc` if `timestamp` is taken, and leaving the template alone with
164 // a warning if both are)
165 if !tokens.iter().any(|(_, token)| token == "utc") {
166 let taken = |key: &str| {
167 tokens.iter().any(|(k, _)| k == key) || constants.iter().any(|(k, _)| k == key)
168 };
169 if !taken("timestamp") {
170 tokens.push(("timestamp".to_string(), "utc".to_string()));
171 } else if !taken("utc") {
172 tokens.push(("utc".to_string(), "utc".to_string()));
173 } else {
174 log::warn!(
175 "Log context template maps $utc to no key and both 'timestamp' and 'utc' \
176 are taken - no UTC timestamp is added to the context block"
177 );
178 }
179 }
180 log::info!(
181 "Application log context enabled with {} context key-value(s)",
182 tokens.len() + constants.len()
183 );
184 }
185 LogContextConfig {
186 enabled,
187 tokens,
188 constants,
189 }
190 }
191
192 pub fn is_enabled(&self) -> bool {
193 self.enabled
194 }
195
196 /// Build the context block for one log line (Java `render`): reserved
197 /// tokens resolved live, constants, then the developer's custom keys.
198 /// Keys resolving to nothing are omitted.
199 ///
200 /// `state` is the current trace bracket — the context block exists ONLY
201 /// for a log line emitted inside a traced function execution with a real
202 /// request trace (Java parity: the log context is registered per worker
203 /// execution in lockstep with the trace bracket; framework, system and
204 /// telemetry lines carry no context at all, not even the constants).
205 pub fn render(
206 &self,
207 state: &trace::TraceState,
208 log_time: std::time::SystemTime,
209 ) -> serde_json::Map<String, serde_json::Value> {
210 let mut out = serde_json::Map::new();
211 // developer keys render first, so a template key can never be shadowed
212 // by `update_context` - the template wins (Java 4.12.13)
213 for (key, value) in &state.custom_log_keys {
214 if !value.is_null() {
215 out.insert(key.clone(), value.clone());
216 }
217 }
218 for (output_key, token_name) in &self.tokens {
219 if let Some(value) = state.token(token_name, log_time) {
220 out.insert(output_key.clone(), value);
221 }
222 }
223 for (output_key, constant) in &self.constants {
224 out.insert(
225 output_key.clone(),
226 serde_json::Value::String(constant.clone()),
227 );
228 }
229 out
230 }
231}
232
233/// The three output formats (the log4j2 appender-selection analog):
234/// `text` = the default plain console line (context-free, like Java's plain
235/// `Console` appender); `json` = pretty-print JSON (Java `log4j2-json.xml`);
236/// `compact` = single-line jsonl, no CR/LF within a record (Java
237/// `log4j2-compact.xml`). Both JSON forms carry the `context` block.
238#[derive(Clone, Copy, PartialEq)]
239enum LogFormat {
240 Text,
241 Json,
242 Compact,
243}
244
245impl LogFormat {
246 fn resolve(name: &str) -> LogFormat {
247 match name.to_ascii_lowercase().as_str() {
248 "json" => LogFormat::Json,
249 "compact" => LogFormat::Compact,
250 _ => LogFormat::Text,
251 }
252 }
253}
254
255/// The process logger (the log4j2 appenders' analog).
256struct PlatformLogger {
257 format: LogFormat,
258 level: log::LevelFilter,
259}
260
261impl log::Log for PlatformLogger {
262 fn enabled(&self, metadata: &log::Metadata) -> bool {
263 metadata.level() <= self.level
264 }
265
266 fn log(&self, record: &log::Record) {
267 if !self.enabled(record.metadata()) {
268 return;
269 }
270 let now = std::time::SystemTime::now();
271 let time = trace::iso8601_utc(now);
272 if self.format == LogFormat::Text {
273 println!(
274 "{time} {:<5} [{}] {}",
275 record.level(),
276 record.module_path().unwrap_or("unknown"),
277 record.args()
278 );
279 return;
280 }
281 let mut line = serde_json::Map::new();
282 line.insert("time".into(), serde_json::Value::String(time));
283 line.insert(
284 "level".into(),
285 serde_json::Value::String(record.level().to_string()),
286 );
287 line.insert(
288 "source".into(),
289 serde_json::Value::String(format!(
290 "{}({}:{})",
291 record.module_path().unwrap_or("unknown"),
292 record.file().unwrap_or("?"),
293 record.line().unwrap_or(0)
294 )),
295 );
296 let message = record.args().to_string();
297 // a message that is itself JSON embeds as a structured object
298 // (Java JsonLogger's ObjectMessage handling — the telemetry
299 // dataset renders structured, not as an escaped string)
300 let message_value = if message.starts_with('{') {
301 serde_json::from_str::<serde_json::Value>(&message)
302 .unwrap_or(serde_json::Value::String(message))
303 } else {
304 serde_json::Value::String(message)
305 };
306 line.insert("message".into(), message_value);
307 // the application log context: ONLY inside a traced worker with a
308 // real request trace (Java parity — the context registers per worker
309 // execution in lockstep with the trace bracket; a zero-traced route
310 // registers none). Framework/system/telemetry lines carry no context
311 // block at all — constants never leak onto context-less lines.
312 let config = LogContextConfig::instance();
313 if config.is_enabled() {
314 let context = trace::with_current(|state| {
315 if state.zero_traced {
316 None
317 } else {
318 Some(config.render(state, now))
319 }
320 })
321 .flatten();
322 if let Some(context) = context {
323 if !context.is_empty() {
324 line.insert("context".into(), serde_json::Value::Object(context));
325 }
326 }
327 }
328 let line = serde_json::Value::Object(line);
329 match self.format {
330 // pretty-print JSON, one record over multiple lines
331 LogFormat::Json => println!(
332 "{}",
333 serde_json::to_string_pretty(&line).unwrap_or_else(|_| line.to_string())
334 ),
335 // compact jsonl: one record per line, no CR/LF within a record
336 _ => println!("{line}"),
337 }
338 }
339
340 fn flush(&self) {}
341}
342
343/// Install the process logger, reading `log.format` (`text` | `json` |
344/// `compact`, default `text`) and `log.level` (default `info`; `RUST_LOG` env
345/// wins) from the application configuration. `-Dkey=value` runtime arguments
346/// (the JVM `-D` analog) are loaded into the override registry first, so
347/// `hello_world -- -Dlog.format=json` switches format at launch. Idempotent —
348/// a second call is a no-op (the `log` crate accepts one logger per process).
349pub fn init() {
350 // runtime -D overrides win over configuration files (System.getProperty parity)
351 crate::util::overrides::load_runtime_args();
352 let config = AppConfigReader::get_instance();
353 let format = LogFormat::resolve(&config.get_property_or("log.format", "text"));
354 let level_text = std::env::var("RUST_LOG")
355 .ok()
356 .unwrap_or_else(|| config.get_property_or("log.level", "info"));
357 let level = match level_text.to_ascii_lowercase().as_str() {
358 "error" => log::LevelFilter::Error,
359 "warn" => log::LevelFilter::Warn,
360 "debug" => log::LevelFilter::Debug,
361 "trace" => log::LevelFilter::Trace,
362 "off" => log::LevelFilter::Off,
363 _ => log::LevelFilter::Info,
364 };
365 // initialize the log-context template BEFORE installing the logger: the
366 // JSON logger consults it on every line, and letting the first log line
367 // trigger the lazy init would re-enter the OnceLock from inside its own
368 // initializer (the config logs while loading) — a deadlock
369 let context = LogContextConfig::instance();
370 if log::set_boxed_logger(Box::new(PlatformLogger { format, level })).is_ok() {
371 log::set_max_level(level);
372 if context.is_enabled() {
373 log::info!(
374 "Application log context enabled with {} context key-value(s)",
375 context.tokens.len() + context.constants.len()
376 );
377 }
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn format_resolution() {
387 assert!(matches!(LogFormat::resolve("json"), LogFormat::Json));
388 assert!(matches!(LogFormat::resolve("JSON"), LogFormat::Json));
389 assert!(matches!(LogFormat::resolve("compact"), LogFormat::Compact));
390 assert!(matches!(LogFormat::resolve("text"), LogFormat::Text));
391 assert!(matches!(LogFormat::resolve("unknown"), LogFormat::Text)); // safe default
392 }
393
394 /// One sequential test for the three `load_config_file` outcomes — the
395 /// resolution reads process-global state (overrides, resource roots), so
396 /// the cases must not run as parallel tests.
397 #[test]
398 fn log_context_is_on_by_default_overridable_and_can_opt_out() {
399 // 1. DEFAULT-ON: no app-log-context.yaml on the resource path (this
400 // crate's own resources/ has none) → the built-in default template
401 // enables the feature with the standard trace-context keys
402 let config = LogContextConfig::load_config_file();
403 assert!(config.is_enabled(), "log context must be ON by default");
404 let token_keys: Vec<&str> = config.tokens.iter().map(|(k, _)| k.as_str()).collect();
405 for expected in [
406 "cid",
407 "trace_id",
408 "trace_path",
409 "span_id",
410 "parent_span_id",
411 "service",
412 "timestamp",
413 ] {
414 assert!(
415 token_keys.contains(&expected),
416 "built-in template must carry '{expected}'"
417 );
418 }
419 assert!(
420 config.constants.is_empty(),
421 "built-in default has no constants"
422 );
423
424 // 2. OPT-OUT: app.log.context=false disables the feature entirely
425 crate::util::overrides::set(FEATURE_FLAG, "false");
426 let config = LogContextConfig::load_config_file();
427 crate::util::overrides::clear(FEATURE_FLAG);
428 assert!(!config.is_enabled(), "app.log.context=false must opt out");
429
430 // 3. APP FILE OVERRIDES: an app-log-context.yaml on the resource path
431 // REPLACES the built-in template entirely (no merge)
432 let dir = std::env::temp_dir().join(format!("pc-logctx-default-{}", std::process::id()));
433 std::fs::create_dir_all(&dir).unwrap();
434 std::fs::write(
435 dir.join("app-log-context.yaml"),
436 "context:\n onlyKey: $service\n",
437 )
438 .unwrap();
439 crate::util::resources::prepend_resource_root(&dir);
440 let config = LogContextConfig::load_config_file();
441 assert!(config.is_enabled());
442 assert_eq!(
443 config.tokens,
444 vec![
445 ("onlyKey".to_string(), "service".to_string()),
446 // the engine supplies the UTC timestamp when the template maps $utc to no key
447 ("timestamp".to_string(), "utc".to_string()),
448 ],
449 "the application template must replace the built-in default entirely"
450 );
451 std::fs::remove_dir_all(&dir).ok();
452 }
453
454 /// The automatic UTC timestamp: inserted as `timestamp`, falling back to
455 /// `utc` when `timestamp` is taken, and left out with a warning when both
456 /// are; an explicit `$utc` mapping is kept as authored (no duplicate).
457 #[test]
458 fn utc_timestamp_is_supplied_when_the_template_omits_it() {
459 let reader = |text: &str| ConfigReader::from_yaml_text(text).expect("yaml");
460 let inserted = LogContextConfig::from_reader(&reader("context:\n svc: $service\n"));
461 assert!(inserted
462 .tokens
463 .contains(&("timestamp".to_string(), "utc".to_string())));
464 let explicit =
465 LogContextConfig::from_reader(&reader("context:\n when: $utc\n svc: $service\n"));
466 assert_eq!(
467 1,
468 explicit.tokens.iter().filter(|(_, t)| t == "utc").count()
469 );
470 assert!(explicit
471 .tokens
472 .contains(&("when".to_string(), "utc".to_string())));
473 let fallback = LogContextConfig::from_reader(&reader("context:\n timestamp: $service\n"));
474 assert!(fallback
475 .tokens
476 .contains(&("utc".to_string(), "utc".to_string())));
477 let both_taken = LogContextConfig::from_reader(&reader(
478 "context:\n timestamp: $service\n utc: hello\n",
479 ));
480 assert!(!both_taken.tokens.iter().any(|(_, t)| t == "utc"));
481 }
482
483 /// Developer keys render first and the template wins, so a business key
484 /// can never shadow a template key.
485 #[test]
486 fn template_keys_win_over_developer_keys() {
487 let config = LogContextConfig::from_reader(
488 &ConfigReader::from_yaml_text("context:\n service: $service\n env: dev\n")
489 .expect("yaml"),
490 );
491 let mut state = trace::TraceState::new("greeting.demo", "t1", "GET /x", None, None);
492 state
493 .custom_log_keys
494 .insert("service".to_string(), serde_json::json!("shadow"));
495 state
496 .custom_log_keys
497 .insert("env".to_string(), serde_json::json!("shadow"));
498 state
499 .custom_log_keys
500 .insert("user".to_string(), serde_json::json!("eric"));
501 let out = config.render(&state, std::time::SystemTime::now());
502 assert_eq!("greeting.demo", out["service"]);
503 assert_eq!("dev", out["env"]);
504 assert_eq!("eric", out["user"]);
505 assert!(out.contains_key("timestamp"), "the automatic UTC timestamp");
506 }
507}