dynamo_runtime/
logging.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
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//! Dynamo Distributed Logging Module.
17//!
18//! - Configuration loaded from:
19//!   1. Environment variables (highest priority).
20//!   2. Optional TOML file pointed to by the `DYN_LOGGING_CONFIG_PATH` environment variable.
21//!   3. `/opt/dynamo/etc/logging.toml`.
22//!
23//! Logging can take two forms: `READABLE` or `JSONL`. The default is `READABLE`. `JSONL`
24//! can be enabled by setting the `DYN_LOGGING_JSONL` environment variable to `1`.
25//!
26//! Filters can be configured using the `DYN_LOG` environment variable or by setting the `filters`
27//! key in the TOML configuration file. Filters are comma-separated key-value pairs where the key
28//! is the crate or module name and the value is the log level. The default log level is `info`.
29//!
30//! Example:
31//! ```toml
32//! log_level = "error"
33//!
34//! [log_filters]
35//! "test_logging" = "info"
36//! "test_logging::api" = "trace"
37//! ```
38
39use std::collections::{BTreeMap, HashMap};
40use std::sync::Once;
41
42use figment::{
43    providers::{Format, Serialized, Toml},
44    Figment,
45};
46use serde::{Deserialize, Serialize};
47use tracing::{Event, Subscriber};
48use tracing_subscriber::fmt::{format::Writer, FormattedFields};
49use tracing_subscriber::fmt::{FmtContext, FormatFields};
50use tracing_subscriber::prelude::*;
51use tracing_subscriber::registry::LookupSpan;
52use tracing_subscriber::EnvFilter;
53use tracing_subscriber::{filter::Directive, fmt};
54
55/// ENV used to set the log level
56const FILTER_ENV: &str = "DYN_LOG";
57
58/// Default log level
59const DEFAULT_FILTER_LEVEL: &str = "info";
60
61/// ENV used to set the path to the logging configuration file
62const CONFIG_PATH_ENV: &str = "DYN_LOGGING_CONFIG_PATH";
63
64/// Once instance to ensure the logger is only initialized once
65static INIT: Once = Once::new();
66
67#[derive(Serialize, Deserialize, Debug)]
68struct LoggingConfig {
69    log_level: String,
70    log_filters: HashMap<String, String>,
71}
72impl Default for LoggingConfig {
73    fn default() -> Self {
74        LoggingConfig {
75            log_level: DEFAULT_FILTER_LEVEL.to_string(),
76            log_filters: HashMap::from([
77                ("h2".to_string(), "error".to_string()),
78                ("tower".to_string(), "error".to_string()),
79                ("hyper_util".to_string(), "error".to_string()),
80                ("neli".to_string(), "error".to_string()),
81                ("async_nats".to_string(), "error".to_string()),
82            ]),
83        }
84    }
85}
86
87/// Initialize the logger
88pub fn init() {
89    INIT.call_once(|| {
90        let config = load_config();
91
92        // Examples to remove noise
93        // .add_directive("rustls=warn".parse()?)
94        // .add_directive("tokio_util::codec=warn".parse()?)
95        let mut filter_layer = EnvFilter::builder()
96            .with_default_directive(config.log_level.parse().unwrap())
97            .with_env_var(FILTER_ENV)
98            .from_env_lossy();
99
100        // apply the log_filters from the config files
101        for (module, level) in config.log_filters {
102            match format!("{module}={level}").parse::<Directive>() {
103                Ok(d) => {
104                    filter_layer = filter_layer.add_directive(d);
105                }
106                Err(e) => {
107                    eprintln!("Failed parsing filter '{level}' for module '{module}': {e}");
108                }
109            }
110        }
111
112        if crate::config::jsonl_logging_enabled() {
113            let l = fmt::layer()
114                .with_ansi(false) // ansi terminal escapes and colors always disabled
115                .event_format(CustomJsonFormatter)
116                .with_writer(std::io::stderr)
117                .with_filter(filter_layer);
118            //let l = fmt::layer().json().with_filter(filter_layer);
119            tracing_subscriber::registry().with(l).init();
120        } else {
121            let l = fmt::layer()
122                .with_ansi(!crate::config::disable_ansi_logging())
123                .event_format(fmt::format().compact())
124                .with_writer(std::io::stderr)
125                .with_filter(filter_layer);
126            tracing_subscriber::registry().with(l).init();
127        };
128    });
129}
130
131/// Log a message with file and line info
132/// Used by Python wrapper
133pub fn log_message(level: &str, message: &str, module: &str, file: &str, line: u32) {
134    let level = match level {
135        "debug" => log::Level::Debug,
136        "info" => log::Level::Info,
137        "warn" => log::Level::Warn,
138        "error" => log::Level::Error,
139        "warning" => log::Level::Warn,
140        _ => log::Level::Info,
141    };
142    log::logger().log(
143        &log::Record::builder()
144            .args(format_args!("{}", message))
145            .level(level)
146            .target(module)
147            .file(Some(file))
148            .line(Some(line))
149            .build(),
150    );
151}
152
153// TODO: This should be merged into the global config (rust/common/src/config.rs) once we have it
154fn load_config() -> LoggingConfig {
155    let config_path = std::env::var(CONFIG_PATH_ENV).unwrap_or_else(|_| "".to_string());
156    let figment = Figment::new()
157        .merge(Serialized::defaults(LoggingConfig::default()))
158        .merge(Toml::file("/opt/dynamo/etc/logging.toml"))
159        .merge(Toml::file(config_path));
160
161    figment.extract().unwrap()
162}
163
164#[derive(Serialize)]
165struct JsonLog<'a> {
166    time: String,
167    level: String,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    file_path: Option<&'a str>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    line_number: Option<u32>,
172    message: serde_json::Value,
173    #[serde(flatten)]
174    fields: BTreeMap<String, serde_json::Value>,
175}
176
177/// Some teams (NVCF) require specific JSON style
178struct CustomJsonFormatter;
179
180impl<S, N> tracing_subscriber::fmt::FormatEvent<S, N> for CustomJsonFormatter
181where
182    S: Subscriber + for<'a> LookupSpan<'a>,
183    N: for<'a> FormatFields<'a> + 'static,
184{
185    fn format_event(
186        &self,
187        ctx: &FmtContext<'_, S, N>,
188        mut writer: Writer<'_>,
189        event: &Event<'_>,
190    ) -> std::fmt::Result {
191        let mut visitor = JsonVisitor::default();
192        event.record(&mut visitor);
193        let message = visitor
194            .fields
195            .remove("message")
196            .unwrap_or(serde_json::Value::String("".to_string()));
197
198        let current_span = event
199            .parent()
200            .and_then(|id| ctx.span(id))
201            .or_else(|| ctx.lookup_current());
202        if let Some(span) = current_span {
203            let ext = span.extensions();
204            // This won't work is there's a space in the string, and loses the types making every
205            // span attribute a string.
206            // I think the correct way is to make a Layer.
207            // tracing_subscriber makes everything far more complicated than necessary.
208            let data = ext.get::<FormattedFields<N>>().unwrap();
209            let span_fields: Vec<(&str, &str)> = data
210                .fields
211                .split(' ')
212                .filter_map(|entry| entry.split_once('='))
213                .collect();
214            for (name, value) in span_fields {
215                visitor.fields.insert(
216                    name.to_string(),
217                    serde_json::Value::String(value.trim_matches('"').to_string()),
218                );
219            }
220            visitor.fields.insert(
221                "span_name".to_string(),
222                serde_json::Value::String(span.name().to_string()),
223            );
224        }
225
226        let metadata = event.metadata();
227        let log = JsonLog {
228            level: metadata.level().to_string(),
229            time: format!("{}", chrono::Local::now().format("%m-%d %H:%M:%S.%3f")),
230            file_path: if cfg!(debug_assertions) {
231                metadata.file()
232            } else {
233                None
234            },
235            line_number: if cfg!(debug_assertions) {
236                metadata.line()
237            } else {
238                None
239            },
240            message,
241            fields: visitor.fields,
242        };
243        let json = serde_json::to_string(&log).unwrap();
244        writeln!(writer, "{json}")
245    }
246}
247
248// Visitor to collect fields
249#[derive(Default)]
250struct JsonVisitor {
251    // BTreeMap so that it's sorted, and always prints in the same order
252    fields: BTreeMap<String, serde_json::Value>,
253}
254
255impl tracing::field::Visit for JsonVisitor {
256    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
257        self.fields.insert(
258            field.name().to_string(),
259            serde_json::Value::String(format!("{value:?}")),
260        );
261    }
262
263    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
264        self.fields.insert(
265            field.name().to_string(),
266            serde_json::Value::String(value.to_string()),
267        );
268    }
269
270    fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
271        self.fields
272            .insert(field.name().to_string(), serde_json::Value::Bool(value));
273    }
274
275    fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
276        self.fields.insert(
277            field.name().to_string(),
278            serde_json::Value::Number(value.into()),
279        );
280    }
281
282    fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
283        self.fields.insert(
284            field.name().to_string(),
285            serde_json::Value::Number(value.into()),
286        );
287    }
288
289    fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
290        use serde_json::value::Number;
291        self.fields.insert(
292            field.name().to_string(),
293            // Infinite or NaN values are not JSON numbers, replace them with 0.
294            // It's unlikely that we would log an inf or nan value.
295            serde_json::Value::Number(Number::from_f64(value).unwrap_or(0.into())),
296        );
297    }
298}