saluki_components/destinations/dsd_debug_log/
mod.rs1use std::{io::Write, path::PathBuf};
2
3use agent_data_plane_config::Live;
4use async_trait::async_trait;
5use chrono::{DateTime, Utc};
6use saluki_common::collections::FastHashMap;
7use saluki_core::{
8 accounting::{MemoryBounds, MemoryBoundsBuilder},
9 components::{
10 destinations::{Destination, DestinationBuilder, DestinationContext},
11 BuildContext,
12 },
13 data_model::{
14 event::{metric::Metric, Event, EventType},
15 tags::TagSet,
16 },
17};
18use saluki_error::{generic_error, GenericError};
19use stringtheory::MetaString;
20use tokio::select;
21use tracing::{debug, warn};
22use tracing_appender::non_blocking::{NonBlocking, NonBlockingBuilder, WorkerGuard};
23use tracing_rolling_file::{RollingConditionBase, RollingFileAppenderBase};
24
25const DEBUG_LOG_WRITER_BUFFER_LINES: usize = 4096;
26
27pub struct DogStatsDDebugLogConfiguration {
29 pub metrics_stats_enabled: Live<bool>,
33
34 pub log_file: PathBuf,
36
37 pub log_file_max_size: u64,
39
40 pub log_file_max_rolls: usize,
42}
43
44struct DogStatsDDebugLog {
46 log_file: PathBuf,
47 log_file_max_size: u64,
48 log_file_max_rolls: usize,
49 writer: Option<DebugLogWriter>,
50 metrics_stats_enabled: Live<bool>,
51 stats: FastHashMap<ContextNoOrigin, MetricSample>,
52}
53
54struct DebugLogWriter {
55 writer: NonBlocking,
56 _guard: WorkerGuard,
57}
58
59#[derive(Debug, Default)]
60struct MetricSample {
61 count: u64,
62 last_seen: u64,
63}
64
65#[derive(Eq, Hash, PartialEq)]
66struct ContextNoOrigin {
67 name: MetaString,
68 tags: TagSet,
69}
70
71impl DogStatsDDebugLog {
72 fn new(config: &DogStatsDDebugLogConfiguration) -> Result<Self, GenericError> {
73 let mut destination = Self {
74 log_file: config.log_file.clone(),
75 log_file_max_size: config.log_file_max_size,
76 log_file_max_rolls: config.log_file_max_rolls,
77 writer: None,
78 metrics_stats_enabled: config.metrics_stats_enabled.clone(),
79 stats: FastHashMap::default(),
80 };
81
82 if *destination.metrics_stats_enabled {
83 destination.ensure_writer()?;
84 }
85
86 Ok(destination)
87 }
88
89 fn process_metric(&mut self, metric: &Metric) -> Result<(), GenericError> {
90 if !*self.metrics_stats_enabled {
91 return Ok(());
92 }
93
94 self.write_metric(metric)
95 }
96
97 fn write_metric(&mut self, metric: &Metric) -> Result<(), GenericError> {
98 self.ensure_writer()?;
99
100 let context = metric.context();
101 let metric_context = ContextNoOrigin {
102 name: context.name().clone(),
103 tags: context.tags().clone(),
104 };
105
106 let timestamp = saluki_common::time::get_coarse_unix_timestamp();
107 let sample = self.stats.entry(metric_context).or_default();
108 sample.count += 1;
109 sample.last_seen = timestamp;
110
111 let writer = self.writer.as_mut().expect("writer should be initialized");
112 writeln!(
113 writer.writer,
114 "Metric Name: {} | Tags: {{{}}} | Count: {} | Last Seen: {}",
115 context.name(),
116 format_tags(context.tags()),
117 sample.count,
118 format_timestamp(sample.last_seen)
119 )
120 .map_err(|e| {
121 generic_error!(
122 "Failed to write to DogStatsD debug log file '{}': {}",
123 self.log_file.display(),
124 e
125 )
126 })
127 }
128
129 fn ensure_writer(&mut self) -> Result<(), GenericError> {
130 if self.writer.is_some() {
131 return Ok(());
132 }
133
134 let appender = RollingFileAppenderBase::new(
135 &self.log_file,
136 RollingConditionBase::new().max_size(self.log_file_max_size),
137 self.log_file_max_rolls,
138 )
139 .map_err(|e| generic_error!("Failed to open dogstatsd_log_file '{}': {}", self.log_file.display(), e))?;
140
141 let (writer, guard) = NonBlockingBuilder::default()
142 .thread_name("dsd-dbg-writer")
143 .buffered_lines_limit(DEBUG_LOG_WRITER_BUFFER_LINES)
144 .lossy(true)
146 .finish(appender);
147
148 self.writer = Some(DebugLogWriter { writer, _guard: guard });
149
150 Ok(())
151 }
152}
153
154#[async_trait]
155impl Destination for DogStatsDDebugLog {
156 async fn run(mut self: Box<Self>, mut context: DestinationContext) -> Result<(), GenericError> {
157 let mut health = context.take_health_handle();
158 health.mark_ready();
159
160 loop {
161 select! {
162 _ = health.live() => continue,
163 maybe_events = context.events().next() => match maybe_events {
164 Some(events) => {
165 for event in events {
166 if let Event::Metric(metric) = event {
167 if let Err(error) = self.process_metric(&metric) {
168 warn!(error = %error, "Failed to write DogStatsD debug log line; continuing.");
169 }
170 }
171 }
172 },
173 None => break,
174 },
175 metrics_stats_enabled = self.metrics_stats_enabled.changed() => {
176 debug!(metrics_stats_enabled, "Updated DogStatsD metrics stats debug logging gate.");
177 },
178 }
179 }
180
181 Ok(())
182 }
183}
184
185#[async_trait]
186impl DestinationBuilder for DogStatsDDebugLogConfiguration {
187 fn input_event_type(&self) -> EventType {
188 EventType::Metric
189 }
190
191 async fn build(&self, _context: BuildContext) -> Result<Box<dyn Destination + Send>, GenericError> {
192 DogStatsDDebugLog::new(self).map(|destination| Box::new(destination) as Box<dyn Destination + Send>)
193 }
194}
195
196impl MemoryBounds for DogStatsDDebugLogConfiguration {
197 fn specify_bounds(&self, builder: &mut MemoryBoundsBuilder) {
198 builder
199 .minimum()
200 .with_single_value::<DogStatsDDebugLog>("component struct");
201 }
202}
203
204fn format_tags(tags: &TagSet) -> String {
205 let mut formatted = String::new();
206
207 for tag in tags {
208 if !formatted.is_empty() {
209 formatted.push(' ');
210 }
211 formatted.push_str(tag.as_str());
212 }
213
214 formatted
215}
216
217fn format_timestamp(timestamp: u64) -> String {
218 i64::try_from(timestamp)
219 .ok()
220 .and_then(|ts| DateTime::<Utc>::from_timestamp(ts, 0))
221 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S +0000 UTC").to_string())
222 .unwrap_or_else(|| timestamp.to_string())
223}
224
225#[cfg(test)]
226mod tests {
227 use std::{
228 fs,
229 path::{Path, PathBuf},
230 };
231 use std::{sync::Arc, time::Duration};
232
233 use agent_data_plane_config::{Live, SalukiConfiguration};
234 use saluki_core::{
235 components::test_util::TestComponentDriver,
236 data_model::event::{
237 metric::{context::Context, Metric},
238 Event,
239 },
240 };
241 use tempfile::tempdir;
242
243 use super::{DogStatsDDebugLog, DogStatsDDebugLogConfiguration};
244
245 fn test_config(log_file: PathBuf, max_size: u64, max_rolls: usize) -> DogStatsDDebugLogConfiguration {
246 DogStatsDDebugLogConfiguration {
247 metrics_stats_enabled: Live::new_fixed(true),
248 log_file,
249 log_file_max_size: max_size,
250 log_file_max_rolls: max_rolls,
251 }
252 }
253
254 fn read_log_files(log_file: &Path, max_rolls: usize) -> String {
255 let mut output = String::new();
256
257 for roll in (0..=max_rolls).rev() {
258 let path = rolled_path(log_file, roll);
259 if path.exists() {
260 output.push_str(&fs::read_to_string(&path).expect("debug log file should be readable"));
261 }
262 }
263
264 output
265 }
266
267 fn rolled_path(log_file: &Path, roll: usize) -> PathBuf {
268 if roll == 0 {
269 log_file.to_path_buf()
270 } else {
271 PathBuf::from(format!("{}.{}", log_file.display(), roll))
272 }
273 }
274
275 fn tagged_metric() -> Metric {
276 let context = Context::from_static_parts("custom.metric", &["env:prod", "service:web"]);
277 Metric::counter(context, 1.0)
278 }
279
280 #[tokio::test]
281 async fn writes_metric_debug_lines_and_updates_count() {
282 let tempdir = tempdir().expect("temporary directory should be created");
283 let log_file = tempdir.path().join("dogstatsd-stats.log");
284 let config = test_config(log_file.clone(), 64_000, 3);
285 let metric = tagged_metric();
286
287 let mut destination = DogStatsDDebugLog::new(&config).expect("debug log destination should be built");
288 destination
289 .write_metric(&metric)
290 .expect("first metric should be written");
291 destination
292 .write_metric(&metric)
293 .expect("second metric should be written");
294 drop(destination);
295
296 let output = read_log_files(&log_file, config.log_file_max_rolls);
297 let lines = output.lines().collect::<Vec<_>>();
298
299 assert_eq!(lines.len(), 2);
300 assert!(lines[0].contains("Metric Name: custom.metric"));
301 assert!(lines[0].contains("Tags: {env:prod service:web}"));
302 assert!(lines[0].contains("Count: 1"));
303 assert!(lines[0].contains("Last Seen: "));
304 assert!(lines[1].contains("Count: 2"));
305 }
306
307 #[tokio::test]
308 async fn run_starts_and_stops_logging_with_metrics_stats_setting() {
309 let tempdir = tempdir().expect("temporary directory should be created");
310 let log_file = tempdir.path().join("dogstatsd-stats.log");
311 let cell = Arc::new(arc_swap::ArcSwap::from_pointee(SalukiConfiguration::default()));
312 let (tick_tx, tick_rx) = tokio::sync::watch::channel(());
313 let mut config = test_config(log_file.clone(), 64_000, 3);
314 config.metrics_stats_enabled = Live::new_dynamic(Arc::clone(&cell), tick_rx, |config| {
315 &config.domains.dogstatsd.debug_log.metrics_stats_enable
316 });
317 let log_file_max_rolls = config.log_file_max_rolls;
318 let control = TestComponentDriver::destination(config)
319 .await
320 .expect("debug log destination should be built");
321
322 control.send_event(Event::Metric(tagged_metric())).await;
323 control.wait_until_input_drained().await;
324 assert!(!log_file.exists());
325
326 let mut updated = (*cell.load_full()).clone();
327 updated.domains.dogstatsd.debug_log.metrics_stats_enable = true;
328 cell.store(Arc::new(updated));
329 tick_tx.send_replace(());
330
331 tokio::time::timeout(Duration::from_secs(2), async {
332 loop {
333 control.send_event(Event::Metric(tagged_metric())).await;
334 tokio::time::sleep(Duration::from_millis(10)).await;
335 if fs::read_to_string(&log_file).is_ok_and(|output| output.contains("Metric Name: custom.metric")) {
336 break;
337 }
338 }
339 })
340 .await
341 .expect("metrics should be logged after the runtime setting is enabled");
342
343 let mut updated = (*cell.load_full()).clone();
344 updated.domains.dogstatsd.debug_log.metrics_stats_enable = false;
345 cell.store(Arc::new(updated));
346 tick_tx.send_replace(());
347
348 let line_count_after_disable = tokio::time::timeout(Duration::from_secs(2), async {
349 let mut previous_line_count = read_log_files(&log_file, log_file_max_rolls).lines().count();
350 let mut unchanged_samples = 0;
351
352 loop {
353 control.send_event(Event::Metric(tagged_metric())).await;
354 control.wait_until_input_drained().await;
355 tokio::time::sleep(Duration::from_millis(20)).await;
356
357 let current_line_count = read_log_files(&log_file, log_file_max_rolls).lines().count();
358 if current_line_count == previous_line_count {
359 unchanged_samples += 1;
360 if unchanged_samples == 5 {
361 break current_line_count;
362 }
363 } else {
364 previous_line_count = current_line_count;
365 unchanged_samples = 0;
366 }
367 }
368 })
369 .await
370 .expect("metrics should stop being logged after the runtime setting is disabled");
371
372 for _ in 0..3 {
373 control.send_event(Event::Metric(tagged_metric())).await;
374 }
375 control.wait_until_input_drained().await;
376 tokio::time::sleep(Duration::from_millis(100)).await;
377
378 let output = read_log_files(&log_file, log_file_max_rolls);
379 assert_eq!(output.lines().count(), line_count_after_disable);
380
381 control.shutdown().await.expect("destination should stop cleanly");
382 }
383
384 #[tokio::test]
385 async fn rotates_log_file_at_configured_size() {
386 let tempdir = tempdir().expect("temporary directory should be created");
387 let log_file = tempdir.path().join("dogstatsd-stats.log");
388 let min_debug_line_len =
389 "Metric Name: custom.metric | Tags: {env:prod service:web} | Count: 1 | Last Seen: ".len();
390 let config = test_config(log_file.clone(), min_debug_line_len as u64, 2);
391 let metric = tagged_metric();
392
393 let mut destination = DogStatsDDebugLog::new(&config).expect("debug log destination should be built");
394 for _ in 0..12 {
395 destination.write_metric(&metric).expect("metric should be written");
396 }
397 drop(destination);
398
399 assert!(log_file.exists());
400 assert!(rolled_path(&log_file, 1).exists());
401 assert!(rolled_path(&log_file, 2).exists());
402 assert!(!rolled_path(&log_file, 3).exists());
403
404 let output = read_log_files(&log_file, config.log_file_max_rolls);
405 assert!(output.contains("Metric Name: custom.metric"));
406 }
407
408 #[tokio::test]
409 async fn build_error_mentions_log_file_config_key_and_path() {
410 let tempdir = tempdir().expect("temporary directory should be created");
411 let blocked_parent = tempdir.path().join("not-a-directory");
412 fs::write(&blocked_parent, "not a directory").expect("blocking file should be written");
413 let log_file = blocked_parent.join("dogstatsd-stats.log");
414 let config = test_config(log_file.clone(), 64_000, 3);
415
416 let err = match DogStatsDDebugLog::new(&config) {
417 Ok(_) => panic!("build should fail"),
418 Err(err) => err,
419 };
420 let err = err.to_string();
421
422 assert!(err.contains("dogstatsd_log_file"));
423 assert!(err.contains(&log_file.display().to_string()));
424 }
425}