Skip to main content

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}