1use 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
16pub const FIRST_PARTY_LOG_TARGETS: &[&str] = &["datadog_agent", "saluki"];
26
27#[derive(Clone, Debug)]
32#[non_exhaustive]
33pub struct LoggingSettings {
34 pub level: String,
39
40 pub format_json: bool,
42
43 pub format_rfc3339: bool,
45
46 pub to_console: bool,
48
49 pub to_syslog: bool,
51
52 pub syslog_rfc: bool,
55
56 pub syslog_uri: String,
60
61 pub file: Option<String>,
66
67 pub disable_file_logging: bool,
69
70 pub file_max_rolls: usize,
72
73 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#[derive(Clone, Debug)]
107pub struct LoggingTranslator {
108 default_log_file: PathBuf,
109 first_party_targets: Vec<&'static str>,
110}
111
112impl LoggingTranslator {
113 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 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 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 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 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 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 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 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 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}