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 log::info!(
158 "Application log context enabled with {} context key-value(s)",
159 tokens.len() + constants.len()
160 );
161 }
162 LogContextConfig {
163 enabled,
164 tokens,
165 constants,
166 }
167 }
168
169 pub fn is_enabled(&self) -> bool {
170 self.enabled
171 }
172
173 /// Build the context block for one log line (Java `render`): reserved
174 /// tokens resolved live, constants, then the developer's custom keys.
175 /// Keys resolving to nothing are omitted.
176 ///
177 /// `state` is the current trace bracket — the context block exists ONLY
178 /// for a log line emitted inside a traced function execution with a real
179 /// request trace (Java parity: the log context is registered per worker
180 /// execution in lockstep with the trace bracket; framework, system and
181 /// telemetry lines carry no context at all, not even the constants).
182 pub fn render(
183 &self,
184 state: &trace::TraceState,
185 log_time: std::time::SystemTime,
186 ) -> serde_json::Map<String, serde_json::Value> {
187 let mut out = serde_json::Map::new();
188 for (output_key, token_name) in &self.tokens {
189 if let Some(value) = state.token(token_name, log_time) {
190 out.insert(output_key.clone(), value);
191 }
192 }
193 for (output_key, constant) in &self.constants {
194 out.insert(
195 output_key.clone(),
196 serde_json::Value::String(constant.clone()),
197 );
198 }
199 for (key, value) in &state.custom_log_keys {
200 if !value.is_null() {
201 out.insert(key.clone(), value.clone());
202 }
203 }
204 out
205 }
206}
207
208/// The three output formats (the log4j2 appender-selection analog):
209/// `text` = the default plain console line (context-free, like Java's plain
210/// `Console` appender); `json` = pretty-print JSON (Java `log4j2-json.xml`);
211/// `compact` = single-line jsonl, no CR/LF within a record (Java
212/// `log4j2-compact.xml`). Both JSON forms carry the `context` block.
213#[derive(Clone, Copy, PartialEq)]
214enum LogFormat {
215 Text,
216 Json,
217 Compact,
218}
219
220impl LogFormat {
221 fn resolve(name: &str) -> LogFormat {
222 match name.to_ascii_lowercase().as_str() {
223 "json" => LogFormat::Json,
224 "compact" => LogFormat::Compact,
225 _ => LogFormat::Text,
226 }
227 }
228}
229
230/// The process logger (the log4j2 appenders' analog).
231struct PlatformLogger {
232 format: LogFormat,
233 level: log::LevelFilter,
234}
235
236impl log::Log for PlatformLogger {
237 fn enabled(&self, metadata: &log::Metadata) -> bool {
238 metadata.level() <= self.level
239 }
240
241 fn log(&self, record: &log::Record) {
242 if !self.enabled(record.metadata()) {
243 return;
244 }
245 let now = std::time::SystemTime::now();
246 let time = trace::iso8601_utc(now);
247 if self.format == LogFormat::Text {
248 println!(
249 "{time} {:<5} [{}] {}",
250 record.level(),
251 record.module_path().unwrap_or("unknown"),
252 record.args()
253 );
254 return;
255 }
256 let mut line = serde_json::Map::new();
257 line.insert("time".into(), serde_json::Value::String(time));
258 line.insert(
259 "level".into(),
260 serde_json::Value::String(record.level().to_string()),
261 );
262 line.insert(
263 "source".into(),
264 serde_json::Value::String(format!(
265 "{}({}:{})",
266 record.module_path().unwrap_or("unknown"),
267 record.file().unwrap_or("?"),
268 record.line().unwrap_or(0)
269 )),
270 );
271 let message = record.args().to_string();
272 // a message that is itself JSON embeds as a structured object
273 // (Java JsonLogger's ObjectMessage handling — the telemetry
274 // dataset renders structured, not as an escaped string)
275 let message_value = if message.starts_with('{') {
276 serde_json::from_str::<serde_json::Value>(&message)
277 .unwrap_or(serde_json::Value::String(message))
278 } else {
279 serde_json::Value::String(message)
280 };
281 line.insert("message".into(), message_value);
282 // the application log context: ONLY inside a traced worker with a
283 // real request trace (Java parity — the context registers per worker
284 // execution in lockstep with the trace bracket; a zero-traced route
285 // registers none). Framework/system/telemetry lines carry no context
286 // block at all — constants never leak onto context-less lines.
287 let config = LogContextConfig::instance();
288 if config.is_enabled() {
289 let context = trace::with_current(|state| {
290 if state.zero_traced {
291 None
292 } else {
293 Some(config.render(state, now))
294 }
295 })
296 .flatten();
297 if let Some(context) = context {
298 if !context.is_empty() {
299 line.insert("context".into(), serde_json::Value::Object(context));
300 }
301 }
302 }
303 let line = serde_json::Value::Object(line);
304 match self.format {
305 // pretty-print JSON, one record over multiple lines
306 LogFormat::Json => println!(
307 "{}",
308 serde_json::to_string_pretty(&line).unwrap_or_else(|_| line.to_string())
309 ),
310 // compact jsonl: one record per line, no CR/LF within a record
311 _ => println!("{line}"),
312 }
313 }
314
315 fn flush(&self) {}
316}
317
318/// Install the process logger, reading `log.format` (`text` | `json` |
319/// `compact`, default `text`) and `log.level` (default `info`; `RUST_LOG` env
320/// wins) from the application configuration. `-Dkey=value` runtime arguments
321/// (the JVM `-D` analog) are loaded into the override registry first, so
322/// `hello_world -- -Dlog.format=json` switches format at launch. Idempotent —
323/// a second call is a no-op (the `log` crate accepts one logger per process).
324pub fn init() {
325 // runtime -D overrides win over configuration files (System.getProperty parity)
326 crate::util::overrides::load_runtime_args();
327 let config = AppConfigReader::get_instance();
328 let format = LogFormat::resolve(&config.get_property_or("log.format", "text"));
329 let level_text = std::env::var("RUST_LOG")
330 .ok()
331 .unwrap_or_else(|| config.get_property_or("log.level", "info"));
332 let level = match level_text.to_ascii_lowercase().as_str() {
333 "error" => log::LevelFilter::Error,
334 "warn" => log::LevelFilter::Warn,
335 "debug" => log::LevelFilter::Debug,
336 "trace" => log::LevelFilter::Trace,
337 "off" => log::LevelFilter::Off,
338 _ => log::LevelFilter::Info,
339 };
340 // initialize the log-context template BEFORE installing the logger: the
341 // JSON logger consults it on every line, and letting the first log line
342 // trigger the lazy init would re-enter the OnceLock from inside its own
343 // initializer (the config logs while loading) — a deadlock
344 let context = LogContextConfig::instance();
345 if log::set_boxed_logger(Box::new(PlatformLogger { format, level })).is_ok() {
346 log::set_max_level(level);
347 if context.is_enabled() {
348 log::info!(
349 "Application log context enabled with {} context key-value(s)",
350 context.tokens.len() + context.constants.len()
351 );
352 }
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn format_resolution() {
362 assert!(matches!(LogFormat::resolve("json"), LogFormat::Json));
363 assert!(matches!(LogFormat::resolve("JSON"), LogFormat::Json));
364 assert!(matches!(LogFormat::resolve("compact"), LogFormat::Compact));
365 assert!(matches!(LogFormat::resolve("text"), LogFormat::Text));
366 assert!(matches!(LogFormat::resolve("unknown"), LogFormat::Text)); // safe default
367 }
368
369 /// One sequential test for the three `load_config_file` outcomes — the
370 /// resolution reads process-global state (overrides, resource roots), so
371 /// the cases must not run as parallel tests.
372 #[test]
373 fn log_context_is_on_by_default_overridable_and_can_opt_out() {
374 // 1. DEFAULT-ON: no app-log-context.yaml on the resource path (this
375 // crate's own resources/ has none) → the built-in default template
376 // enables the feature with the standard trace-context keys
377 let config = LogContextConfig::load_config_file();
378 assert!(config.is_enabled(), "log context must be ON by default");
379 let token_keys: Vec<&str> = config.tokens.iter().map(|(k, _)| k.as_str()).collect();
380 for expected in [
381 "cid",
382 "traceId",
383 "tracePath",
384 "spanId",
385 "parentSpanId",
386 "service",
387 "timestamp",
388 ] {
389 assert!(
390 token_keys.contains(&expected),
391 "built-in template must carry '{expected}'"
392 );
393 }
394 assert!(
395 config.constants.is_empty(),
396 "built-in default has no constants"
397 );
398
399 // 2. OPT-OUT: app.log.context=false disables the feature entirely
400 crate::util::overrides::set(FEATURE_FLAG, "false");
401 let config = LogContextConfig::load_config_file();
402 crate::util::overrides::clear(FEATURE_FLAG);
403 assert!(!config.is_enabled(), "app.log.context=false must opt out");
404
405 // 3. APP FILE OVERRIDES: an app-log-context.yaml on the resource path
406 // REPLACES the built-in template entirely (no merge)
407 let dir = std::env::temp_dir().join(format!("pc-logctx-default-{}", std::process::id()));
408 std::fs::create_dir_all(&dir).unwrap();
409 std::fs::write(
410 dir.join("app-log-context.yaml"),
411 "context:\n onlyKey: $service\n",
412 )
413 .unwrap();
414 crate::util::resources::prepend_resource_root(&dir);
415 let config = LogContextConfig::load_config_file();
416 assert!(config.is_enabled());
417 assert_eq!(
418 config.tokens,
419 vec![("onlyKey".to_string(), "service".to_string())],
420 "the application template must replace the built-in default entirely"
421 );
422 std::fs::remove_dir_all(&dir).ok();
423 }
424}