1use crate::buffer::{BufferMode, Disk, SegmentWriter};
21use crate::clock;
22use crate::handle::Dial9Handle;
23use crate::primitives::sync::Arc;
24use crate::recording::{Recorder, RecordingStartHook};
25use crate::shared_state::SharedState;
26use crate::source::Source;
27
28type RecordingThreadHook = Arc<dyn Fn() -> Box<dyn FnOnce() + Send> + Send + Sync>;
32
33fn noop_thread_hook() -> RecordingThreadHook {
34 Arc::new(|| Box::new(|| {}) as Box<dyn FnOnce() + Send>)
35}
36
37pub(crate) fn merge_segment_metadata(
41 existing: &mut Vec<(String, String)>,
42 entries: impl IntoIterator<Item = (String, String)>,
43) {
44 let incoming: Vec<(String, String)> = entries.into_iter().collect();
45 existing.retain(|(k, _)| !incoming.iter().any(|(ik, _)| ik == k));
46 existing.extend(incoming);
47}
48
49pub fn recorder<M: BufferMode>(writer: SegmentWriter<M>) -> RecorderBuilder<M> {
54 builder_with(Some(writer))
55}
56
57pub fn recorder_or_disabled<M: BufferMode>(
66 writer: std::io::Result<SegmentWriter<M>>,
67) -> RecorderBuilder<M> {
68 match writer {
69 Ok(writer) => builder_with(Some(writer)),
70 Err(e) => {
71 tracing::error!(
72 target: "dial9_telemetry",
73 "dial9: trace writer setup failed; running without telemetry: {e}"
74 );
75 builder_with(None)
76 }
77 }
78}
79
80fn builder_with<M: BufferMode>(writer: Option<SegmentWriter<M>>) -> RecorderBuilder<M> {
81 RecorderBuilder {
82 writer,
83 sources: Vec::new(),
84 recording_start_hooks: Vec::new(),
85 segment_metadata: Vec::new(),
86 metrics_sink: None,
87 thread_init: noop_thread_hook(),
88 #[cfg(feature = "pipeline")]
89 pipeline: None,
90 #[cfg(feature = "pipeline")]
91 terminal_processor: None,
92 #[cfg(feature = "pipeline")]
93 worker_poll_interval: None,
94 #[cfg(feature = "pipeline")]
95 trigger: None,
96 #[cfg(feature = "pipeline")]
97 pending_dump_trigger: None,
98 enabled: true,
99 }
100}
101
102pub fn recorder_disabled() -> crate::recording::Recorder {
109 crate::recording::Recorder::new(crate::handle::Dial9Handle::disabled(), None)
110}
111
112#[cfg(feature = "pipeline")]
119fn default_pipeline(
120 source_stages: Vec<Box<dyn crate::pipeline::SegmentProcessor>>,
121 terminal: Option<Box<dyn crate::pipeline::SegmentProcessor>>,
122 is_disk: bool,
123) -> Vec<Box<dyn crate::pipeline::SegmentProcessor>> {
124 if source_stages.is_empty() && terminal.is_none() {
125 return Vec::new();
126 }
127 let mut processors = source_stages;
128 processors.push(Box::new(crate::worker::processors::GzipCompressor));
129 match terminal {
130 Some(terminal) => processors.push(terminal),
131 None if is_disk => processors.push(Box::new(
132 crate::worker::processors::WriteBackProcessor::default(),
133 )),
134 None => {}
135 }
136 processors
137}
138
139#[must_use = "call `.build()` to start recording"]
141pub struct RecorderBuilder<M: BufferMode = Disk> {
142 writer: Option<SegmentWriter<M>>,
145 sources: Vec<Box<dyn Source>>,
146 recording_start_hooks: Vec<RecordingStartHook>,
147 segment_metadata: Vec<(String, String)>,
148 metrics_sink: Option<metrique::writer::BoxEntrySink>,
149 thread_init: RecordingThreadHook,
150 #[cfg(feature = "pipeline")]
153 pipeline: Option<Vec<Box<dyn crate::pipeline::SegmentProcessor>>>,
154 #[cfg(feature = "pipeline")]
158 terminal_processor: Option<Box<dyn crate::pipeline::SegmentProcessor>>,
159 #[cfg(feature = "pipeline")]
160 worker_poll_interval: Option<std::time::Duration>,
161 #[cfg(feature = "pipeline")]
162 trigger: Option<crate::dump::DumpRx>,
163 #[cfg(feature = "pipeline")]
166 pending_dump_trigger: Option<crate::dump::DumpTrigger>,
167 enabled: bool,
170}
171
172impl<M: BufferMode> std::fmt::Debug for RecorderBuilder<M> {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 f.debug_struct("RecorderBuilder")
175 .field("sources", &self.sources.len())
176 .finish_non_exhaustive()
177 }
178}
179
180impl<M: BufferMode> RecorderBuilder<M> {
181 pub fn source(mut self, source: impl Source + 'static) -> Self {
183 self.sources.push(Box::new(source));
184 self
185 }
186
187 pub fn source_names(&self) -> impl Iterator<Item = &str> + '_ {
189 self.sources.iter().map(|s| s.name())
190 }
191
192 pub fn writer_boot_id(&self) -> Option<&str> {
195 self.writer.as_ref()?.boot_id()
196 }
197
198 pub fn segment_metadata(mut self, entries: impl IntoIterator<Item = (String, String)>) -> Self {
219 merge_segment_metadata(&mut self.segment_metadata, entries);
220 self
221 }
222
223 pub fn metrics_sink(mut self, sink: metrique::writer::BoxEntrySink) -> Self {
226 self.metrics_sink = Some(sink);
227 self
228 }
229
230 pub fn on_recording_thread_start<F, T>(mut self, hook: F) -> Self
234 where
235 F: Fn() -> T + Send + Sync + 'static,
236 T: FnOnce() + Send + 'static,
237 {
238 self.thread_init = Arc::new(move || Box::new(hook()) as Box<dyn FnOnce() + Send>);
239 self
240 }
241
242 pub fn build(self) -> Recorder {
251 #[allow(unused_mut)]
252 let Some(mut writer) = self.writer else {
253 return recorder_disabled();
254 };
255
256 let shared = Arc::new(SharedState::new(clock::clock_monotonic_ns()));
257
258 #[cfg(feature = "pipeline")]
261 if let Some(trigger) = self.pending_dump_trigger {
262 shared.set_dump_trigger(trigger);
263 }
264
265 let mut segment_metadata = self.segment_metadata;
269 if let Some(boot_id) = writer.boot_id().map(str::to_owned) {
270 for (key, value) in &mut segment_metadata {
271 if key == "boot_id" {
272 *value = boot_id.clone();
273 }
274 }
275 }
276 if !segment_metadata.is_empty() {
277 writer.update_segment_metadata(segment_metadata);
278 }
279
280 #[allow(unused_mut)]
281 let mut sources = self.sources;
282
283 #[cfg(feature = "pipeline")]
286 let processors = match self.pipeline {
287 Some(processors) => processors,
288 None => {
289 let source_stages = sources
290 .iter_mut()
291 .filter_map(|source| source.segment_processor())
292 .collect();
293 default_pipeline(source_stages, self.terminal_processor, M::IS_DISK)
294 }
295 };
296
297 for source in sources {
298 shared.push_source(source);
299 }
300
301 #[cfg(feature = "pipeline")]
304 let worker = if processors.is_empty() {
305 None
306 } else {
307 let poll = self
308 .worker_poll_interval
309 .unwrap_or(crate::worker::DEFAULT_POLL_INTERVAL);
310 let metrics = self
311 .metrics_sink
312 .clone()
313 .unwrap_or_else(metrique::writer::sink::DevNullSink::boxed);
314 let config = crate::worker::BackgroundTaskConfig::builder()
315 .maybe_trace_dir(M::IS_DISK.then(|| writer.trace_dir().to_path_buf()))
316 .maybe_trace_stem(M::IS_DISK.then(|| writer.trace_stem().to_string()))
317 .poll_interval(poll)
318 .processors(processors)
319 .metrics_sink(metrics)
320 .maybe_trigger(self.trigger)
321 .build();
322 let (tx, rx) = tokio::sync::oneshot::channel();
323 let hook = self.thread_init.clone();
324 crate::worker::spawn(&writer, config, rx, move || hook())
325 .map(|wt| crate::recording::WorkerHandle::new(tx, wt))
326 };
327
328 let hook = self.thread_init.clone();
329 #[allow(unused_mut)]
330 let mut recorder = Recorder::start(shared, writer, self.metrics_sink, move || hook());
331
332 #[cfg(feature = "pipeline")]
333 if let Some(worker) = worker {
334 recorder.attach_worker(worker);
335 }
336
337 recorder.set_recording_start_hooks(self.recording_start_hooks);
338
339 if self.enabled {
340 recorder.enable();
341 }
342 recorder
343 }
344
345 pub fn paused(mut self) -> Self {
351 self.enabled = false;
352 self
353 }
354}
355
356pub trait RecorderSourceExt: Sized {
364 fn source(self, source: impl Source + 'static) -> Self;
366
367 fn on_recording_start(self, hook: impl FnOnce(&Dial9Handle) + Send + 'static) -> Self;
370
371 fn with_custom_events<F>(
376 self,
377 config: crate::custom_events::CustomEventsConfig,
378 callback: F,
379 ) -> Self
380 where
381 F: for<'a> FnMut(&mut crate::custom_events::CustomEventsContext<'a>) + Send + 'static,
382 {
383 self.source(crate::custom_events::CustomEventsSource::new(
384 config, callback,
385 ))
386 }
387}
388
389impl<M: BufferMode> RecorderSourceExt for RecorderBuilder<M> {
390 fn source(mut self, source: impl Source + 'static) -> Self {
391 self.sources.push(Box::new(source));
392 self
393 }
394
395 fn on_recording_start(mut self, hook: impl FnOnce(&Dial9Handle) + Send + 'static) -> Self {
396 self.recording_start_hooks.push(Box::new(hook));
397 self
398 }
399}
400
401#[cfg(feature = "pipeline")]
402impl<M: BufferMode> RecorderBuilder<M> {
403 pub fn pipe(mut self, processor: impl crate::pipeline::SegmentProcessor + 'static) -> Self {
406 self.pipeline
407 .get_or_insert_default()
408 .push(Box::new(processor));
409 self
410 }
411
412 pub fn processors(
416 mut self,
417 processors: Vec<Box<dyn crate::pipeline::SegmentProcessor>>,
418 ) -> Self {
419 self.pipeline = Some(processors);
420 self
421 }
422
423 pub fn terminal_processor(
431 mut self,
432 processor: impl crate::pipeline::SegmentProcessor + 'static,
433 ) -> Self {
434 self.terminal_processor = Some(Box::new(processor));
435 self
436 }
437
438 pub fn worker_poll_interval(mut self, interval: std::time::Duration) -> Self {
440 self.worker_poll_interval = Some(interval);
441 self
442 }
443
444 pub fn trigger(mut self, trigger: crate::dump::DumpRx) -> Self {
447 self.trigger = Some(trigger);
448 self
449 }
450
451 pub fn with_dump_trigger<F>(mut self, configure: F) -> Self
458 where
459 F: FnOnce(&mut crate::dump::DumpTriggerConfig),
460 {
461 let mut config = crate::dump::DumpTriggerConfig::new();
462 configure(&mut config);
463 let (mut trigger, rx) = crate::dump::channel();
464 if let Some(window) = config.debounce_window() {
465 trigger = trigger.with_debounce(window);
466 }
467 self.trigger = Some(rx);
468 self.pending_dump_trigger = Some(trigger);
469 self
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use crate::buffer::{DiskBuffer, MemoryBuffer};
477 use crate::source::FlushContext;
478 use dial9_trace_format::TraceEvent;
479 use dial9_trace_format::decoder::Decoder;
480 use std::path::{Path, PathBuf};
481 use std::time::Duration;
482
483 #[derive(Debug, serde::Deserialize, TraceEvent)]
484 struct TestEvent {
485 #[traceevent(timestamp)]
486 timestamp_ns: u64,
487 value: u64,
488 }
489
490 struct OnceSource {
492 emitted: bool,
493 value: u64,
494 }
495
496 impl Source for OnceSource {
497 fn flush(&mut self, ctx: &FlushContext<'_>) {
498 if !self.emitted {
499 self.emitted = true;
500 ctx.record_event(&TestEvent {
501 timestamp_ns: clock::clock_monotonic_ns(),
502 value: self.value,
503 });
504 }
505 }
506 fn name(&self) -> &'static str {
507 "once"
508 }
509 }
510
511 fn sealed_segment(dir: &Path) -> PathBuf {
512 std::fs::read_dir(dir)
513 .expect("trace dir readable")
514 .filter_map(|e| e.ok().map(|e| e.path()))
515 .find(|p| {
516 let name = p.file_name().unwrap().to_string_lossy();
517 name.ends_with(".bin") && !name.ends_with(".active")
518 })
519 .expect("a sealed .bin segment")
520 }
521
522 fn decoded_test_values(bytes: &[u8]) -> Vec<u64> {
523 let mut decoder = Decoder::new(bytes).expect("valid trace header");
524 let mut values = Vec::new();
525 decoder
526 .for_each_event(|raw| {
527 if raw.name == "TestEvent" {
528 let event: TestEvent = raw.deserialize().expect("TestEvent decodes");
529 values.push(event.value);
530 }
531 })
532 .expect("decode events");
533 values
534 }
535
536 #[test]
539 fn records_source_events_to_disk() {
540 let dir = tempfile::tempdir().expect("tempdir");
541 let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
542
543 let recorder = recorder(writer)
544 .segment_metadata([("service".to_string(), "recorder-test".to_string())])
545 .source(OnceSource {
546 emitted: false,
547 value: 7,
548 })
549 .build();
550 recorder.graceful_shutdown(Duration::ZERO);
551
552 let bytes = std::fs::read(sealed_segment(dir.path())).expect("read segment");
553 assert!(
554 decoded_test_values(&bytes).contains(&7),
555 "the source's event should round-trip through the trace file"
556 );
557 }
558
559 #[test]
561 fn build_starts_recording() {
562 let writer = MemoryBuffer::new(1 << 20).expect("writer");
563 let recorder = recorder(writer).build();
564 assert!(
565 recorder.shared().expect("live recorder").is_enabled(),
566 "build() must start recording"
567 );
568 }
569
570 #[test]
572 fn paused_build_waits_for_enable() {
573 let writer = MemoryBuffer::new(1 << 20).expect("writer");
574 let recorder = recorder(writer).paused().build();
575 assert!(
576 !recorder.shared().expect("live recorder").is_enabled(),
577 "paused() must leave recording off"
578 );
579 assert!(
581 !recorder.handle().is_enabled(),
582 "a paused handle must report disabled"
583 );
584 assert!(
585 recorder.handle().shared().is_some(),
586 "a paused handle is still connected"
587 );
588 recorder.enable();
589 assert!(
590 recorder.shared().expect("live recorder").is_enabled(),
591 "recording on after enable()"
592 );
593 assert!(
594 recorder.handle().is_enabled(),
595 "handle enabled after enable()"
596 );
597 }
598
599 #[test]
602 fn on_recording_start_runs_once_when_recording_starts() {
603 use std::sync::Arc as StdArc;
604 use std::sync::atomic::{AtomicUsize, Ordering};
605
606 let runs = StdArc::new(AtomicUsize::new(0));
607 let runs_hook = StdArc::clone(&runs);
608 let live = recorder(MemoryBuffer::new(1 << 20).expect("writer"))
609 .on_recording_start(move |_handle| {
610 runs_hook.fetch_add(1, Ordering::SeqCst);
611 })
612 .build();
613 assert_eq!(runs.load(Ordering::SeqCst), 1, "hook runs at build");
614 live.enable();
615 assert_eq!(runs.load(Ordering::SeqCst), 1, "hook runs at most once");
616
617 let paused_runs = StdArc::new(AtomicUsize::new(0));
618 let paused_hook = StdArc::clone(&paused_runs);
619 let paused = recorder(MemoryBuffer::new(1 << 20).expect("writer"))
620 .on_recording_start(move |_handle| {
621 paused_hook.fetch_add(1, Ordering::SeqCst);
622 })
623 .paused()
624 .build();
625 assert_eq!(
626 paused_runs.load(Ordering::SeqCst),
627 0,
628 "paused build must not run the hook yet"
629 );
630 paused.enable();
631 assert_eq!(paused_runs.load(Ordering::SeqCst), 1, "hook runs on enable");
632 }
633
634 #[cfg(feature = "pipeline")]
637 #[test]
638 fn pipe_runs_the_background_worker() {
639 use crate::pipeline::{ProcessError, SegmentData, SegmentProcessor};
640 use std::future::Future;
641 use std::pin::Pin;
642 use std::sync::Arc as StdArc;
643 use std::sync::atomic::{AtomicUsize, Ordering};
644
645 #[derive(Debug)]
646 struct CountingProcessor(StdArc<AtomicUsize>);
647 impl SegmentProcessor for CountingProcessor {
648 fn name(&self) -> &'static str {
649 "Counting"
650 }
651 fn process(
652 &mut self,
653 data: SegmentData,
654 ) -> Pin<Box<dyn Future<Output = Result<SegmentData, ProcessError>> + Send + '_>>
655 {
656 self.0.fetch_add(1, Ordering::SeqCst);
657 Box::pin(async move { Ok(data) })
658 }
659 }
660
661 let dir = tempfile::tempdir().expect("tempdir");
662 let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
663 let processed = StdArc::new(AtomicUsize::new(0));
664
665 let recorder = recorder(writer)
666 .source(OnceSource {
667 emitted: false,
668 value: 11,
669 })
670 .pipe(CountingProcessor(StdArc::clone(&processed)))
671 .build();
672 recorder.graceful_shutdown(Duration::from_secs(5));
673
674 assert!(
675 processed.load(Ordering::SeqCst) >= 1,
676 "the background worker should process the sealed segment"
677 );
678 }
679
680 #[test]
681 fn segment_metadata_merges_last_key_wins() {
682 let mut md = vec![
683 ("service".to_string(), "checkout".to_string()),
684 ("region".to_string(), "us-east-1".to_string()),
685 ];
686 super::merge_segment_metadata(
687 &mut md,
688 [
689 ("region".to_string(), "eu-west-1".to_string()),
690 ("bucket".to_string(), "traces".to_string()),
691 ],
692 );
693
694 assert!(md.contains(&("service".to_string(), "checkout".to_string())));
695 assert!(md.contains(&("region".to_string(), "eu-west-1".to_string())));
696 assert!(md.contains(&("bucket".to_string(), "traces".to_string())));
697 assert!(
698 !md.iter().any(|(k, v)| k == "region" && v == "us-east-1"),
699 "the colliding key's old value must be gone"
700 );
701 }
702 #[cfg(feature = "pipeline")]
705 #[test]
706 fn source_stage_joins_the_default_pipeline() {
707 use crate::pipeline::{ProcessError, SegmentData, SegmentProcessor};
708 use std::future::Future;
709 use std::pin::Pin;
710 use std::sync::Arc as StdArc;
711 use std::sync::atomic::{AtomicUsize, Ordering};
712
713 struct MarkerStage(StdArc<AtomicUsize>);
714 impl SegmentProcessor for MarkerStage {
715 fn name(&self) -> &'static str {
716 "Marker"
717 }
718 fn process(
719 &mut self,
720 data: SegmentData,
721 ) -> Pin<Box<dyn Future<Output = Result<SegmentData, ProcessError>> + Send + '_>>
722 {
723 self.0.fetch_add(1, Ordering::SeqCst);
724 Box::pin(async move { Ok(data) })
725 }
726 }
727
728 struct StagedSource(StdArc<AtomicUsize>);
730 impl Source for StagedSource {
731 fn flush(&mut self, _ctx: &FlushContext<'_>) {}
732 fn name(&self) -> &'static str {
733 "staged"
734 }
735 fn segment_processor(&mut self) -> Option<Box<dyn SegmentProcessor>> {
736 Some(Box::new(MarkerStage(StdArc::clone(&self.0))))
737 }
738 }
739
740 let dir = tempfile::tempdir().expect("tempdir");
741 let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
742 let ran = StdArc::new(AtomicUsize::new(0));
743
744 let recorder = recorder(writer)
745 .source(StagedSource(StdArc::clone(&ran)))
746 .source(OnceSource {
747 emitted: false,
748 value: 3,
749 })
750 .build();
751 recorder.graceful_shutdown(Duration::from_secs(5));
752
753 assert!(
754 ran.load(Ordering::SeqCst) >= 1,
755 "the source's stage should run in the default pipeline"
756 );
757 let gzipped = std::fs::read_dir(dir.path())
758 .expect("trace dir readable")
759 .filter_map(|e| e.ok().map(|e| e.path()))
760 .any(|p| p.to_string_lossy().ends_with(".bin.gz"));
761 assert!(gzipped, "the default pipeline compresses and writes back");
762 }
763
764 #[cfg(feature = "pipeline")]
766 #[test]
767 fn default_pipeline_is_empty_without_stages() {
768 let dir = tempfile::tempdir().expect("tempdir");
769 let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
770
771 let recorder = recorder(writer)
772 .source(OnceSource {
773 emitted: false,
774 value: 5,
775 })
776 .build();
777 recorder.graceful_shutdown(Duration::from_secs(5));
778
779 let compressed = std::fs::read_dir(dir.path())
780 .expect("trace dir readable")
781 .filter_map(|e| e.ok().map(|e| e.path()))
782 .any(|p| p.to_string_lossy().ends_with(".gz"));
783 assert!(
784 !compressed,
785 "no stages means no worker, so nothing is gzipped"
786 );
787 let bytes = std::fs::read(sealed_segment(dir.path())).expect("read segment");
789 assert_eq!(decoded_test_values(&bytes), vec![5]);
790 }
791
792 #[cfg(feature = "pipeline")]
795 #[test]
796 fn terminal_processor_replaces_write_back() {
797 use crate::pipeline::{ProcessError, SegmentData, SegmentProcessor};
798 use std::future::Future;
799 use std::pin::Pin;
800 use std::sync::Arc as StdArc;
801 use std::sync::atomic::{AtomicUsize, Ordering};
802
803 struct Uploader(StdArc<AtomicUsize>);
804 impl SegmentProcessor for Uploader {
805 fn name(&self) -> &'static str {
806 "Uploader"
807 }
808 fn process(
809 &mut self,
810 data: SegmentData,
811 ) -> Pin<Box<dyn Future<Output = Result<SegmentData, ProcessError>> + Send + '_>>
812 {
813 self.0.fetch_add(1, Ordering::SeqCst);
814 Box::pin(async move { Ok(data) })
815 }
816 }
817
818 let dir = tempfile::tempdir().expect("tempdir");
819 let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
820 let uploaded = StdArc::new(AtomicUsize::new(0));
821
822 let recorder = recorder(writer)
823 .source(OnceSource {
824 emitted: false,
825 value: 9,
826 })
827 .terminal_processor(Uploader(StdArc::clone(&uploaded)))
828 .build();
829 recorder.graceful_shutdown(Duration::from_secs(5));
830
831 assert!(
832 uploaded.load(Ordering::SeqCst) >= 1,
833 "the terminal stage runs on its own"
834 );
835 let written_back = std::fs::read_dir(dir.path())
836 .expect("trace dir readable")
837 .filter_map(|e| e.ok().map(|e| e.path()))
838 .any(|p| p.to_string_lossy().ends_with(".bin.gz"));
839 assert!(!written_back, "the terminal stage takes write-back's place");
840 }
841}