Skip to main content

datadog_agent_runtime/
logging.rs

1//! Translation between a remote agent's logging settings and the logging stack's own configuration.
2//!
3//! Remote agent logging must follow the Datadog Agent's logging configuration for the settings that are sensibly
4//! shared (level, format, console output, rotation), but each remote agent must use its own destination so it doesn't
5//! collide with the Core Agent's own log file. This module owns those rules in one place.
6
7use std::{future, path::PathBuf};
8
9use bytesize::ByteSize;
10use datadog_agent_commons::platform::PlatformSettings;
11use futures::{Stream, StreamExt as _};
12use saluki_app::logging::{LogLevel, LoggingConfiguration};
13use saluki_error::{generic_error, ErrorContext as _, GenericError};
14use tracing::warn;
15
16/// Log targets that a plain log level applies to, regardless of which remote agent is running.
17///
18/// These cover the Saluki and Datadog crates that remote agents are built from. `tracing` targets use Rust crate and
19/// module names, so Cargo package names with hyphens appear with underscores. A remote agent adds its own crates with
20/// [`LoggingTranslator::with_first_party_targets`].
21///
22/// Each entry is a target prefix, so `saluki` covers every `saluki_*` crate and `datadog_agent` covers every
23/// `datadog_agent_*` crate. A prefix also covers any third-party crate whose name starts with it, which is why the list
24/// uses `datadog_agent` rather than `datadog`.
25pub const FIRST_PARTY_LOG_TARGETS: &[&str] = &["datadog_agent", "saluki"];
26
27/// Logging settings shared with the Datadog Agent.
28///
29/// These mirror the Datadog Agent's own logging settings. A remote agent fills them in from its configuration system
30/// and hands them to a [`LoggingTranslator`].
31#[derive(Clone, Debug)]
32#[non_exhaustive]
33pub struct LoggingSettings {
34    /// Log level: either a plain level name (`trace`, `debug`, `info`, `warn`, `error`, or `off`) or a comma-separated
35    /// list of filter directives.
36    ///
37    /// A plain level name only applies to first-party log targets. Defaults to `info`.
38    pub level: String,
39
40    /// Whether to emit log records as JSON. Defaults to `false`.
41    pub format_json: bool,
42
43    /// Whether to use RFC 3339 timestamps in log output. Defaults to `false`.
44    pub format_rfc3339: bool,
45
46    /// Whether to write log records to standard output. Defaults to `true`.
47    pub to_console: bool,
48
49    /// Whether to write log records to syslog. Defaults to `false`.
50    pub to_syslog: bool,
51
52    /// Whether to use the Agent's RFC-style syslog header. Only applies when `to_syslog` is `true`. Defaults to
53    /// `false`.
54    pub syslog_rfc: bool,
55
56    /// URI of the syslog destination. Only applies when `to_syslog` is `true`.
57    ///
58    /// Defaults to empty, which selects the platform's default local syslog URI.
59    pub syslog_uri: String,
60
61    /// Path to the log file, if one was configured explicitly.
62    ///
63    /// Defaults to `None`, which selects the remote agent's default log file. An explicitly empty path also selects
64    /// the default log file.
65    pub file: Option<String>,
66
67    /// Whether to disable logging to a file entirely. Takes precedence over `file`. Defaults to `false`.
68    pub disable_file_logging: bool,
69
70    /// Maximum number of rolled-over log files to retain. Defaults to `1`.
71    pub file_max_rolls: usize,
72
73    /// Maximum size, in bytes, of a log file before it's rolled over, if one was configured explicitly.
74    ///
75    /// Defaults to `None`, which keeps the logging stack's own default of 10 MiB.
76    pub file_max_size: Option<u64>,
77}
78
79impl Default for LoggingSettings {
80    fn default() -> Self {
81        Self {
82            level: "info".to_string(),
83            format_json: false,
84            format_rfc3339: false,
85            to_console: true,
86            to_syslog: false,
87            syslog_rfc: false,
88            syslog_uri: String::new(),
89            file: None,
90            disable_file_logging: false,
91            file_max_rolls: 1,
92            file_max_size: None,
93        }
94    }
95}
96
97/// Logging configuration translator for matching the Datadog Agent's logging behavior.
98///
99/// In the Datadog Agent, all processes generally follow the same logging configuration, paying attention to the same
100/// settings for determining log level, log format, and so on. They differ in some ways, such as determining what file
101/// to write to when logging to file is enabled. Remote agents follow the same pattern.
102///
103/// `LoggingTranslator` takes [`LoggingSettings`] and generates a Saluki-oriented [`LoggingConfiguration`] from it,
104/// applying the remote agent's own default log file and first-party log targets. This ensures that a remote agent obeys
105/// all the logging configuration rules set by the Datadog Agent but logs to the right location for its own process.
106#[derive(Clone, Debug)]
107pub struct LoggingTranslator {
108    default_log_file: PathBuf,
109    first_party_targets: Vec<&'static str>,
110}
111
112impl LoggingTranslator {
113    /// Creates a new `LoggingTranslator` that logs to `default_log_file` unless a log file is configured explicitly.
114    ///
115    /// A plain log level applies to [`FIRST_PARTY_LOG_TARGETS`]. Add the remote agent's own crates with
116    /// [`with_first_party_targets`](Self::with_first_party_targets).
117    pub fn new(default_log_file: impl Into<PathBuf>) -> Self {
118        Self {
119            default_log_file: default_log_file.into(),
120            first_party_targets: FIRST_PARTY_LOG_TARGETS.to_vec(),
121        }
122    }
123
124    /// Adds the given log targets to the ones that a plain log level applies to.
125    ///
126    /// Like the entries in [`FIRST_PARTY_LOG_TARGETS`], each target is a prefix, so `my_remote_agent` also covers a
127    /// `my_remote_agent_config` crate.
128    pub fn with_first_party_targets(mut self, targets: &[&'static str]) -> Self {
129        self.first_party_targets.extend_from_slice(targets);
130        self
131    }
132
133    /// Builds a [`LoggingConfiguration`] from the given settings, applying this remote agent's rules.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error if the configured log level is not a level name or a valid set of filter directives.
138    pub fn translate(&self, settings: LoggingSettings) -> Result<LoggingConfiguration, GenericError> {
139        let mut config = LoggingConfiguration::simple();
140
141        config.log_level = self.parse_log_level(&settings.level)?;
142        config.log_format_json = settings.format_json;
143        config.log_format_rfc3339 = settings.format_rfc3339;
144        config.log_to_console = settings.to_console;
145        config.log_to_syslog = settings.to_syslog;
146
147        if settings.to_syslog {
148            config.syslog_rfc = settings.syslog_rfc;
149            config.syslog_uri = if settings.syslog_uri.is_empty() {
150                PlatformSettings::get_default_syslog_uri().to_string()
151            } else {
152                settings.syslog_uri
153            };
154        }
155
156        // Preserve the logging stack's binary default when no maximum size was configured.
157        if let Some(file_max_size) = settings.file_max_size {
158            config.log_file_max_size = ByteSize::b(file_max_size);
159        }
160        config.log_file_max_rolls = settings.file_max_rolls;
161
162        // Use the remote agent's default unless its log file was set explicitly.
163        config.log_file = if settings.disable_file_logging {
164            String::new()
165        } else {
166            match &settings.file {
167                Some(file) if !file.is_empty() => file.clone(),
168                _ => self.default_log_file.to_string_lossy().into_owned(),
169            }
170        };
171
172        Ok(config)
173    }
174
175    /// Translates each log level from `levels` into the form that
176    /// [`DynamicLogLevelWorker`][saluki_app::logging::DynamicLogLevelWorker] applies.
177    ///
178    /// Each level is parsed the same way as [`LoggingSettings::level`] in [`translate`](Self::translate), so a plain
179    /// level applies to the same first-party log targets as at startup. A level that can't be parsed is logged and
180    /// skipped, which leaves the current log level in place.
181    pub fn translate_log_levels<S>(&self, levels: S) -> impl Stream<Item = LogLevel> + Send + 'static
182    where
183        S: Stream<Item = String> + Send + 'static,
184    {
185        let translator = self.clone();
186        levels.filter_map(move |level| {
187            future::ready(match translator.parse_log_level(&level) {
188                Ok(log_level) => Some(log_level),
189                Err(e) => {
190                    warn!(error = %e, "Failed to parse updated log level.");
191                    None
192                }
193            })
194        })
195    }
196
197    /// Parses a configured log level, expanding a plain level name into per-target directives.
198    ///
199    /// Plain levels apply to first-party log targets; other values are `target=level` filter directives.
200    fn parse_log_level(&self, value: &str) -> Result<LogLevel, GenericError> {
201        let trimmed = value.trim();
202        if let Some(level) = plain_log_level(trimmed) {
203            self.first_party_log_level_filter(level)
204        } else if !trimmed.contains('=') {
205            // We want to check here to make sure there's a target directive (for example, `foo=info` or
206            // `foo=trace,info`) as if there's not, it means we got a bare level directive (equivalent to getting only
207            // `info`, etc), but with an _invalid_ level.
208            Err(generic_error!("Failed to parse invalid log level '{trimmed}'."))
209        } else {
210            LogLevel::try_from(value.to_string()).error_context("Failed to parse log filter directives.")
211        }
212    }
213
214    fn first_party_log_level_filter(&self, level: &str) -> Result<LogLevel, GenericError> {
215        let filter = self
216            .first_party_targets
217            .iter()
218            .map(|target| format!("{target}={level}"))
219            .collect::<Vec<_>>()
220            .join(",");
221
222        LogLevel::try_from(filter).error_context("Failed to parse first-party log filter directives.")
223    }
224}
225
226fn plain_log_level(value: &str) -> Option<&'static str> {
227    match value.to_ascii_lowercase().as_str() {
228        "trace" => Some("trace"),
229        "debug" => Some("debug"),
230        "info" => Some("info"),
231        "warn" | "warning" => Some("warn"),
232        "error" | "critical" => Some("error"),
233        "off" => Some("off"),
234        _ => None,
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use futures::StreamExt as _;
241    use tracing::Level;
242
243    use super::*;
244
245    const DEFAULT_LOG_FILE: &str = "/var/log/datadog/example-remote-agent.log";
246
247    fn translator() -> LoggingTranslator {
248        LoggingTranslator::new(DEFAULT_LOG_FILE).with_first_party_targets(&["example_remote_agent"])
249    }
250
251    fn translate(settings: LoggingSettings) -> LoggingConfiguration {
252        translator().translate(settings).expect("translate logging config")
253    }
254
255    fn translate_level(level: &str) -> Result<Vec<String>, GenericError> {
256        let settings = LoggingSettings {
257            level: level.to_string(),
258            ..LoggingSettings::default()
259        };
260
261        translator()
262            .translate(settings)
263            .map(|config| config.log_level.as_targets().to_string())
264            .map(|filter| filter.split(',').map(str::to_string).collect())
265    }
266
267    fn translate_log_level(level: &str) -> LogLevel {
268        translate(LoggingSettings {
269            level: level.to_string(),
270            ..LoggingSettings::default()
271        })
272        .log_level
273    }
274
275    #[test]
276    fn default_log_level_becomes_first_party_info() {
277        let directives = translate_level("info").expect("translate logging config");
278
279        assert!(directives.contains(&"example_remote_agent=info".to_string()));
280    }
281
282    #[test]
283    fn plain_log_level_becomes_first_party_filter() {
284        let targets = translate_log_level("warn").as_targets();
285
286        assert!(targets.would_enable("example_remote_agent", &Level::WARN));
287        assert!(targets.would_enable("saluki_components", &Level::WARN));
288        assert!(!targets.would_enable("saluki_components", &Level::INFO));
289
290        for target in ["hyper", "tokio", "tonic"] {
291            assert!(
292                !targets.would_enable(target, &Level::ERROR),
293                "third-party target `{target}` should not be enabled"
294            );
295        }
296    }
297
298    #[test]
299    fn plain_log_level_covers_the_remote_agent_runtime() {
300        // Registration, the configuration stream, and the status, flare, and telemetry services live in this crate, and
301        // `DynamicLogLevelWorker` lives in `saluki_app`, so a plain level must reach their logs no matter which remote
302        // agent is running.
303        let targets = translate_log_level("debug").as_targets();
304
305        assert!(targets.would_enable("datadog_agent_runtime", &Level::DEBUG));
306        assert!(targets.would_enable("saluki_app", &Level::DEBUG));
307    }
308
309    #[test]
310    fn plain_log_level_covers_first_party_crates_by_prefix() {
311        let targets = translate_log_level("debug").as_targets();
312
313        for target in [
314            "saluki_core",
315            "saluki_io::net",
316            "datadog_agent_commons",
317            "datadog_agent_remote_config",
318            "example_remote_agent_config",
319        ] {
320            assert!(
321                targets.would_enable(target, &Level::DEBUG),
322                "first-party target `{target}` should be enabled"
323            );
324        }
325    }
326
327    #[test]
328    fn plain_log_level_is_case_insensitive() {
329        let directives = translate_level("WaRn").expect("translate logging config");
330
331        assert!(directives.contains(&"example_remote_agent=warn".to_string()));
332    }
333
334    #[test]
335    fn advanced_log_level_directives_are_preserved() {
336        let directives =
337            translate_level("warn,example_remote_agent=debug,hyper=warn").expect("translate logging config");
338
339        assert!(directives.contains(&"warn".to_string()));
340        assert!(directives.contains(&"example_remote_agent=debug".to_string()));
341        assert!(directives.contains(&"hyper=warn".to_string()));
342    }
343
344    #[test]
345    fn unparseable_log_level_returns_error() {
346        for level in ["fakelevel", "example_remote_agent=verbose", ""] {
347            assert!(
348                translate_level(level).is_err(),
349                "log level `{level}` should be rejected"
350            );
351        }
352    }
353
354    async fn translate_levels(levels: &[&str]) -> Vec<Vec<String>> {
355        let levels = futures::stream::iter(levels.iter().map(|level| level.to_string()).collect::<Vec<_>>());
356        translator()
357            .translate_log_levels(levels)
358            .map(|log_level| {
359                log_level
360                    .as_targets()
361                    .to_string()
362                    .split(',')
363                    .map(str::to_string)
364                    .collect()
365            })
366            .collect()
367            .await
368    }
369
370    #[tokio::test]
371    async fn translated_log_levels_match_the_startup_translation() {
372        let translated = translate_levels(&["debug", "warn,example_remote_agent=trace"]).await;
373
374        assert_eq!(
375            translated,
376            vec![
377                translate_level("debug").expect("translate logging config"),
378                translate_level("warn,example_remote_agent=trace").expect("translate logging config"),
379            ]
380        );
381    }
382
383    #[tokio::test]
384    async fn unparseable_log_levels_are_skipped() {
385        let translated = translate_levels(&["example_remote_agent=verbose", "", "info"]).await;
386
387        assert_eq!(
388            translated,
389            vec![translate_level("info").expect("translate logging config")]
390        );
391    }
392
393    #[test]
394    fn format_and_console_settings_are_carried_through() {
395        let config = translate(LoggingSettings {
396            format_json: true,
397            format_rfc3339: true,
398            to_console: false,
399            ..LoggingSettings::default()
400        });
401
402        assert!(config.log_format_json);
403        assert!(config.log_format_rfc3339);
404        assert!(!config.log_to_console);
405    }
406
407    #[test]
408    fn defaults_leave_syslog_disabled_with_no_destination() {
409        let config = translate(LoggingSettings::default());
410
411        assert!(!config.log_to_syslog);
412        assert!(config.syslog_uri.is_empty());
413        assert!(!config.syslog_rfc);
414    }
415
416    #[test]
417    fn enabled_syslog_uses_configured_uri_and_framing() {
418        let config = translate(LoggingSettings {
419            to_syslog: true,
420            syslog_uri: "udp://127.0.0.1:1514".to_string(),
421            syslog_rfc: true,
422            ..LoggingSettings::default()
423        });
424
425        assert!(config.log_to_syslog);
426        assert_eq!(config.syslog_uri, "udp://127.0.0.1:1514");
427        assert!(config.syslog_rfc);
428    }
429
430    #[test]
431    fn enabled_syslog_with_empty_uri_uses_platform_default() {
432        let config = translate(LoggingSettings {
433            to_syslog: true,
434            ..LoggingSettings::default()
435        });
436
437        assert!(config.log_to_syslog);
438        assert_eq!(config.syslog_uri, PlatformSettings::get_default_syslog_uri());
439        assert!(!config.syslog_rfc);
440    }
441
442    #[test]
443    fn syslog_settings_have_no_effect_when_syslog_is_disabled() {
444        let config = translate(LoggingSettings {
445            to_syslog: false,
446            syslog_uri: "udp://127.0.0.1:1514".to_string(),
447            syslog_rfc: true,
448            ..LoggingSettings::default()
449        });
450
451        assert!(!config.log_to_syslog);
452        assert!(config.syslog_uri.is_empty());
453        assert!(!config.syslog_rfc);
454    }
455
456    #[test]
457    fn unset_log_file_uses_the_default_path() {
458        let config = translate(LoggingSettings::default());
459
460        assert_eq!(config.log_file, DEFAULT_LOG_FILE);
461    }
462
463    #[test]
464    fn explicitly_configured_log_file_is_used() {
465        let config = translate(LoggingSettings {
466            file: Some("/tmp/remote-agent.log".to_string()),
467            ..LoggingSettings::default()
468        });
469
470        assert_eq!(config.log_file, "/tmp/remote-agent.log");
471    }
472
473    #[test]
474    fn explicitly_empty_log_file_uses_the_default_path() {
475        let config = translate(LoggingSettings {
476            file: Some(String::new()),
477            ..LoggingSettings::default()
478        });
479
480        assert_eq!(config.log_file, DEFAULT_LOG_FILE);
481    }
482
483    #[test]
484    fn disabled_file_logging_overrides_a_configured_log_file() {
485        let config = translate(LoggingSettings {
486            disable_file_logging: true,
487            file: Some("/tmp/remote-agent.log".to_string()),
488            to_syslog: true,
489            ..LoggingSettings::default()
490        });
491
492        assert!(config.log_file.is_empty());
493        assert!(config.log_to_syslog);
494    }
495
496    #[test]
497    fn unset_max_size_keeps_the_binary_rotation_default() {
498        let config = translate(LoggingSettings::default());
499
500        assert_eq!(config.log_file_max_size, ByteSize::mib(10));
501        assert_eq!(config.log_file_max_rolls, 1);
502    }
503
504    #[test]
505    fn explicit_max_size_and_rolls_are_used() {
506        let config = translate(LoggingSettings {
507            file_max_size: Some(1_048_576),
508            file_max_rolls: 5,
509            ..LoggingSettings::default()
510        });
511
512        assert_eq!(config.log_file_max_size, ByteSize::b(1_048_576));
513        assert_eq!(config.log_file_max_rolls, 5);
514    }
515}