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            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}