Skip to main content

dial9_core/
recorder.rs

1//! The recorder builder.
2//!
3//! [`recorder`] assembles a [`Recorder`](crate::recording::Recorder): it
4//! builds the shared bus, registers your [`Source`](crate::source::Source)s,
5//! spawns the flush thread (and, with the `pipeline` feature, the background
6//! worker), and starts recording.
7//!
8//! ```no_run
9//! use dial9_core::buffer::DiskBuffer;
10//! let recorder = dial9_core::recorder::recorder(DiskBuffer::single_file("/tmp/trace.bin")?)
11//!     .build();
12//! // record events through `recorder.handle()`.
13//! # Ok::<(), std::io::Error>(())
14//! ```
15//!
16//! This wraps low-level `Recorder::start`, which expects pre-built shared
17//! recording state with sources already registered. The Tokio integration
18//! reuses the same builder.
19
20use std::sync::atomic::{AtomicBool, Ordering};
21
22use crate::buffer::{BufferMode, Disk, SegmentWriter};
23use crate::clock;
24use crate::handle::Dial9Handle;
25use crate::primitives::sync::Arc;
26use crate::recording::{Recorder, RecordingStartHook};
27use crate::shared_state::SharedState;
28use crate::source::Source;
29
30/// A reusable per-thread hook: run on each recording thread, returning a
31/// teardown closure. Reusable (`Fn`) because both the flush thread and the
32/// background worker each need a fresh `FnOnce`.
33type RecordingThreadHook = Arc<dyn Fn() -> Box<dyn FnOnce() + Send> + Send + Sync>;
34
35fn noop_thread_hook() -> RecordingThreadHook {
36    Arc::new(|| Box::new(|| {}) as Box<dyn FnOnce() + Send>)
37}
38
39/// Merge `entries` into `existing`: on a key collision the incoming value wins.
40/// Matches the writer's segment-metadata merge, so builder-side metadata
41/// accumulates across the core and tokio layers.
42pub(crate) fn merge_segment_metadata(
43    existing: &mut Vec<(String, String)>,
44    entries: impl IntoIterator<Item = (String, String)>,
45) {
46    let incoming: Vec<(String, String)> = entries.into_iter().collect();
47    existing.retain(|(k, _)| !incoming.iter().any(|(ik, _)| ik == k));
48    existing.extend(incoming);
49}
50
51/// Begin building a recorder backed by `writer`.
52///
53/// Register data sources with [`RecorderBuilder::source`], then
54/// [`build`](RecorderBuilder::build), which starts recording.
55pub fn recorder<M: BufferMode>(writer: SegmentWriter<M>) -> RecorderBuilder<M> {
56    builder_with(Some(writer))
57}
58
59/// Begin building a recorder backed by `writer`, or a writer-free disabled one
60/// when the writer could not be created.
61///
62/// Configure it exactly like [`recorder`]: register sources, set a pipeline,
63/// then [`build`](RecorderBuilder::build). When the writer failed, that config
64/// is kept but inert, and `build` yields the same recorder
65/// [`recorder_disabled`] does.
66pub fn recorder_or_disabled<M: BufferMode>(
67    writer: std::io::Result<SegmentWriter<M>>,
68) -> RecorderBuilder<M> {
69    match writer {
70        Ok(writer) => builder_with(Some(writer)),
71        Err(e) => {
72            tracing::error!(
73                target: "dial9_telemetry",
74                "dial9: trace writer setup failed; running without telemetry: {e}"
75            );
76            builder_with(None)
77        }
78    }
79}
80
81fn builder_with<M: BufferMode>(writer: Option<SegmentWriter<M>>) -> RecorderBuilder<M> {
82    RecorderBuilder {
83        writer,
84        sources: Vec::new(),
85        recording_start_hooks: Vec::new(),
86        segment_metadata: Vec::new(),
87        metrics_sink: None,
88        thread_init: noop_thread_hook(),
89        #[cfg(feature = "pipeline")]
90        pipeline: None,
91        #[cfg(feature = "pipeline")]
92        terminal_processor: None,
93        #[cfg(feature = "pipeline")]
94        worker_poll_interval: None,
95        #[cfg(feature = "pipeline")]
96        trigger: None,
97        #[cfg(feature = "pipeline")]
98        pending_dump_trigger: None,
99        enabled: true,
100    }
101}
102
103/// A recorder with recording permanently disabled: no flush thread, no sources,
104/// and [`handle`](crate::recording::Recorder::handle) returns a disabled handle.
105///
106/// Attaching Tokio to it is a no-op and `enable`/`graceful_shutdown` do nothing,
107/// so it is the "telemetry off" fallback for `#[dial9::main]` and
108/// `recorder_or_disabled`: application code runs unchanged, recording nothing.
109pub fn recorder_disabled() -> crate::recording::Recorder {
110    crate::recording::Recorder::new(crate::handle::Dial9Handle::disabled(), None)
111}
112
113/// Proof that this recorder is the only one in the process, released on drop.
114#[derive(Debug)]
115pub(crate) struct SoleRecorderGuard;
116
117impl SoleRecorderGuard {
118    /// Claim the process, or `None` if another recorder holds it.
119    ///
120    /// Always granted under `test-util`, where the suite runs many recorders at
121    /// once.
122    pub(crate) fn claim() -> Option<Self> {
123        match cfg!(feature = "test-util") || !RECORDER_TAKEN.swap(true, Ordering::SeqCst) {
124            true => Some(Self),
125            false => None,
126        }
127    }
128}
129
130impl Drop for SoleRecorderGuard {
131    fn drop(&mut self) {
132        RECORDER_TAKEN.store(false, Ordering::SeqCst);
133    }
134}
135
136static RECORDER_TAKEN: AtomicBool = AtomicBool::new(false);
137
138/// Assemble dial9's default pipeline: source-requested stages
139/// (for example symbolization), then compression, then a terminal stage —
140/// uploader if configured, otherwise disk write-back.
141///
142/// Returns empty when there is no source stage and no uploader; in that case
143/// no worker is spawned.
144#[cfg(feature = "pipeline")]
145fn default_pipeline(
146    source_stages: Vec<Box<dyn crate::pipeline::SegmentProcessor>>,
147    terminal: Option<Box<dyn crate::pipeline::SegmentProcessor>>,
148    is_disk: bool,
149) -> Vec<Box<dyn crate::pipeline::SegmentProcessor>> {
150    if source_stages.is_empty() && terminal.is_none() {
151        return Vec::new();
152    }
153    let mut processors = source_stages;
154    processors.push(Box::new(crate::worker::processors::GzipCompressor));
155    match terminal {
156        Some(terminal) => processors.push(terminal),
157        None if is_disk => processors.push(Box::new(
158            crate::worker::processors::WriteBackProcessor::default(),
159        )),
160        None => {}
161    }
162    processors
163}
164
165/// Builder for a runtime-agnostic [`Recorder`]. See [`recorder`].
166#[must_use = "call `.build()` to start recording"]
167pub struct RecorderBuilder<M: BufferMode = Disk> {
168    /// `None` when the writer could not be created (see
169    /// [`recorder_or_disabled`]); `build` then yields a disabled recorder.
170    writer: Option<SegmentWriter<M>>,
171    sources: Vec<Box<dyn Source>>,
172    recording_start_hooks: Vec<RecordingStartHook>,
173    segment_metadata: Vec<(String, String)>,
174    metrics_sink: Option<metrique::writer::BoxEntrySink>,
175    thread_init: RecordingThreadHook,
176    /// The segment-processing pipeline. `Some` runs exactly these processors,
177    /// `None` assembles dial9's default from the registered sources at build.
178    #[cfg(feature = "pipeline")]
179    pipeline: Option<Vec<Box<dyn crate::pipeline::SegmentProcessor>>>,
180    /// Final stage of the default pipeline, replacing write-back (the S3
181    /// uploader sets it). Applied at build, so it does not depend on the order
182    /// the builder was called in.
183    #[cfg(feature = "pipeline")]
184    terminal_processor: Option<Box<dyn crate::pipeline::SegmentProcessor>>,
185    #[cfg(feature = "pipeline")]
186    worker_poll_interval: Option<std::time::Duration>,
187    #[cfg(feature = "pipeline")]
188    trigger: Option<crate::dump::DumpRx>,
189    /// Dump-trigger sender, installed on the shared state at build so
190    /// `Dial9Handle::dump_trigger` can reach it.
191    #[cfg(feature = "pipeline")]
192    pending_dump_trigger: Option<crate::dump::DumpTrigger>,
193    /// Whether [`build`](RecorderBuilder::build) starts recording. See
194    /// [`paused`](RecorderBuilder::paused).
195    enabled: bool,
196}
197
198impl<M: BufferMode> std::fmt::Debug for RecorderBuilder<M> {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        f.debug_struct("RecorderBuilder")
201            .field("sources", &self.sources.len())
202            .finish_non_exhaustive()
203    }
204}
205
206impl<M: BufferMode> RecorderBuilder<M> {
207    /// Register a [`Source`] drained by the flush thread each cycle.
208    pub fn source(mut self, source: impl Source + 'static) -> Self {
209        self.sources.push(Box::new(source));
210        self
211    }
212
213    /// Names of the registered sources, in registration order.
214    pub fn source_names(&self) -> impl Iterator<Item = &str> + '_ {
215        self.sources.iter().map(|s| s.name())
216    }
217
218    /// The writer's per-process namespace boot id, or `None` before
219    /// [`set_namespace`](SegmentWriter::set_namespace) has run.
220    pub fn writer_boot_id(&self) -> Option<&str> {
221        self.writer.as_ref()?.boot_id()
222    }
223
224    /// Static metadata written into every rotated segment header. Merged across
225    /// calls (and across the tokio layer); on a key collision the later value wins.
226    ///
227    /// # Examples
228    ///
229    /// ```
230    /// use dial9_core::buffer::MemoryBuffer;
231    /// use dial9_core::recorder::recorder;
232    ///
233    /// let recorder = recorder(MemoryBuffer::new(1024 * 1024)?)
234    ///     .segment_metadata([("service".into(), "checkout".into())])
235    ///     .segment_metadata([("environment".into(), "production".into())])
236    ///     .segment_metadata([("service".into(), "payments".into())])
237    ///     .build();
238    ///
239    /// // Metadata is merged across calls. The third call overrides `service`
240    /// // from the first, so its value is `payments`.
241    /// # recorder.graceful_shutdown(std::time::Duration::ZERO);
242    /// # Ok::<(), std::io::Error>(())
243    /// ```
244    pub fn segment_metadata(mut self, entries: impl IntoIterator<Item = (String, String)>) -> Self {
245        merge_segment_metadata(&mut self.segment_metadata, entries);
246        self
247    }
248
249    /// Metrics sink for the flush (and, with `pipeline`, worker) threads.
250    /// Defaults to discarding flush metrics.
251    pub fn metrics_sink(mut self, sink: metrique::writer::BoxEntrySink) -> Self {
252        self.metrics_sink = Some(sink);
253        self
254    }
255
256    /// Start the recorder and begin recording.
257    ///
258    /// Chain [`paused`](Self::paused) beforehand to build without recording, then
259    /// start it later with [`Recorder::enable`].
260    ///
261    /// Yields a disabled recorder when the writer could not be created (see
262    /// [`recorder_or_disabled`]); the sources and pipeline configured on the way
263    /// here are never started.
264    pub fn build(self) -> Recorder {
265        #[allow(unused_mut)]
266        let Some(mut writer) = self.writer else {
267            return recorder_disabled();
268        };
269
270        let Some(sole_recorder) = SoleRecorderGuard::claim() else {
271            tracing::error!(
272                target: "dial9",
273                "dial9: this process already has a recorder, this one will run without telemetry."
274            );
275            return recorder_disabled();
276        };
277
278        let shared = Arc::new(SharedState::new(clock::clock_monotonic_ns()));
279
280        // Install the on-demand dump trigger so `Dial9Handle::dump_trigger` can
281        // reach it (paired with the `trigger` receiver handed to the worker).
282        #[cfg(feature = "pipeline")]
283        if let Some(trigger) = self.pending_dump_trigger {
284            shared.set_dump_trigger(trigger);
285        }
286
287        // Sync any `boot_id` metadata to the writer's per-process namespace, so a
288        // trace's identity matches its on-disk `{boot_id}/` directory (and its
289        // S3 keys).
290        let mut segment_metadata = self.segment_metadata;
291        if let Some(boot_id) = writer.boot_id().map(str::to_owned) {
292            for (key, value) in &mut segment_metadata {
293                if key == "boot_id" {
294                    *value = boot_id.clone();
295                }
296            }
297        }
298        if !segment_metadata.is_empty() {
299            writer.update_segment_metadata(segment_metadata);
300        }
301
302        #[allow(unused_mut)]
303        let mut sources = self.sources;
304
305        // Collect the stages the sources ask for before they move into the
306        // shared state; only the default pipeline uses them.
307        #[cfg(feature = "pipeline")]
308        let processors = match self.pipeline {
309            Some(processors) => processors,
310            None => {
311                let source_stages = sources
312                    .iter_mut()
313                    .filter_map(|source| source.segment_processor())
314                    .collect();
315                default_pipeline(source_stages, self.terminal_processor, M::IS_DISK)
316            }
317        };
318
319        for source in sources {
320            shared.push_source(source);
321        }
322
323        // The worker borrows `&writer`, so it must be spawned before the writer
324        // moves into `Recorder::start`.
325        #[cfg(feature = "pipeline")]
326        let worker = if processors.is_empty() {
327            None
328        } else {
329            let poll = self
330                .worker_poll_interval
331                .unwrap_or(crate::worker::DEFAULT_POLL_INTERVAL);
332            let metrics = self
333                .metrics_sink
334                .clone()
335                .unwrap_or_else(metrique::writer::sink::DevNullSink::boxed);
336            let config = crate::worker::BackgroundTaskConfig::builder()
337                .maybe_trace_dir(M::IS_DISK.then(|| writer.trace_dir().to_path_buf()))
338                .maybe_trace_stem(M::IS_DISK.then(|| writer.trace_stem().to_string()))
339                .poll_interval(poll)
340                .processors(processors)
341                .metrics_sink(metrics)
342                .maybe_trigger(self.trigger)
343                .build();
344            let (tx, rx) = tokio::sync::oneshot::channel();
345            let hook = self.thread_init.clone();
346            crate::worker::spawn(&writer, config, rx, move || hook())
347                .map(|wt| crate::recording::WorkerHandle::new(tx, wt))
348        };
349
350        let hook = self.thread_init.clone();
351        #[allow(unused_mut)]
352        let mut recorder = Recorder::start(shared, writer, self.metrics_sink, move || hook());
353        recorder.hold_process(sole_recorder);
354
355        #[cfg(feature = "pipeline")]
356        if let Some(worker) = worker {
357            recorder.attach_worker(worker);
358        }
359
360        recorder.set_recording_start_hooks(self.recording_start_hooks);
361
362        if self.enabled {
363            recorder.enable();
364        }
365        recorder
366    }
367
368    /// Build without recording. [`Recorder::enable`] starts it later.
369    ///
370    /// Use for a recorder that should exist but stay quiet until something turns
371    /// it on; for permanently-off telemetry prefer [`recorder_disabled`], which
372    /// allocates no writer at all.
373    pub fn paused(mut self) -> Self {
374        self.enabled = false;
375        self
376    }
377}
378
379// TODO(tokio-as-source): now that tokio attaches to a built `Recorder`, this
380// trait has a single implementor. Fold it into inherent `RecorderBuilder`
381// methods once the `RecorderPerfExt` blanket impl can be reworked.
382/// A builder that can register [`Source`]s.
383///
384/// Implemented by [`RecorderBuilder`]; the `.with_*()` perf-source sugar is
385/// built on top of it.
386pub trait RecorderSourceExt: Sized {
387    /// Register a [`Source`] with the underlying recording recorder.
388    fn source(self, source: impl Source + 'static) -> Self;
389
390    /// Register a hook run once, with the live [`Dial9Handle`], when the recorder
391    /// starts recording.
392    fn on_recording_start(self, hook: impl FnOnce(&Dial9Handle) + Send + 'static) -> Self;
393
394    /// Register a hook run on each of dial9's own threads (the flush thread and,
395    /// with `pipeline`, the background worker) before it starts, returning a
396    /// teardown run when it stops. Defaults to a no-op.
397    fn on_recording_thread_start<F, T>(self, hook: F) -> Self
398    where
399        F: Fn() -> T + Send + Sync + 'static,
400        T: FnOnce() + Send + 'static;
401
402    /// Register a callback that dial9 invokes on the flush thread at the config's
403    /// interval to emit custom events. Sugar for [`source`](Self::source) with a
404    /// [`CustomEventsSource`](crate::custom_events::CustomEventsSource). Not
405    /// tokio-coupled — works on the plain recorder and the tokio builder.
406    fn with_custom_events<F>(
407        self,
408        config: crate::custom_events::CustomEventsConfig,
409        callback: F,
410    ) -> Self
411    where
412        F: for<'a> FnMut(&mut crate::custom_events::CustomEventsContext<'a>) + Send + 'static,
413    {
414        self.source(crate::custom_events::CustomEventsSource::new(
415            config, callback,
416        ))
417    }
418}
419
420impl<M: BufferMode> RecorderSourceExt for RecorderBuilder<M> {
421    fn source(mut self, source: impl Source + 'static) -> Self {
422        self.sources.push(Box::new(source));
423        self
424    }
425
426    fn on_recording_start(mut self, hook: impl FnOnce(&Dial9Handle) + Send + 'static) -> Self {
427        self.recording_start_hooks.push(Box::new(hook));
428        self
429    }
430
431    fn on_recording_thread_start<F, T>(mut self, hook: F) -> Self
432    where
433        F: Fn() -> T + Send + Sync + 'static,
434        T: FnOnce() + Send + 'static,
435    {
436        self.thread_init = Arc::new(move || Box::new(hook()) as Box<dyn FnOnce() + Send>);
437        self
438    }
439}
440
441#[cfg(feature = "pipeline")]
442impl<M: BufferMode> RecorderBuilder<M> {
443    /// Append a segment processor (compress, symbolize, upload, write-back),
444    /// replacing dial9's default pipeline with your own stages.
445    pub fn pipe(mut self, processor: impl crate::pipeline::SegmentProcessor + 'static) -> Self {
446        self.pipeline
447            .get_or_insert_default()
448            .push(Box::new(processor));
449        self
450    }
451
452    /// Set the full processor pipeline at once, replacing dial9's default and
453    /// anything added with [`pipe`](Self::pipe). Use this when you already have
454    /// a built list, or `pipe` to append incrementally.
455    pub fn processors(
456        mut self,
457        processors: Vec<Box<dyn crate::pipeline::SegmentProcessor>>,
458    ) -> Self {
459        self.pipeline = Some(processors);
460        self
461    }
462
463    /// Replace write-back as the last stage of the default pipeline, so sealed
464    /// segments are shipped elsewhere instead of written back to disk. This
465    /// also makes processing meaningful even with no other stage, so the worker
466    /// still runs.
467    ///
468    /// Ignored when a custom pipeline is set. The S3 uploader is wired up this
469    /// way.
470    pub fn terminal_processor(
471        mut self,
472        processor: impl crate::pipeline::SegmentProcessor + 'static,
473    ) -> Self {
474        self.terminal_processor = Some(Box::new(processor));
475        self
476    }
477
478    /// How often the background worker polls for sealed segments.
479    pub fn worker_poll_interval(mut self, interval: std::time::Duration) -> Self {
480        self.worker_poll_interval = Some(interval);
481        self
482    }
483
484    /// Trigger receiver switching the worker into on-demand dump mode; see
485    /// [`crate::dump`]. `None` keeps continuous mode.
486    pub fn trigger(mut self, trigger: crate::dump::DumpRx) -> Self {
487        self.trigger = Some(trigger);
488        self
489    }
490
491    /// Enable on-demand dump mode: the background worker runs the pipeline only
492    /// when a dump is requested through the
493    /// [`DumpTrigger`](crate::dump::DumpTrigger), reachable from any recording
494    /// thread via [`Dial9Handle::dump_trigger`](crate::handle::Dial9Handle::dump_trigger).
495    /// Pass `|_| {}` for the default, or `|t| { t.debounce(window); }` to
496    /// coalesce bursts.
497    pub fn with_dump_trigger<F>(mut self, configure: F) -> Self
498    where
499        F: FnOnce(&mut crate::dump::DumpTriggerConfig),
500    {
501        let mut config = crate::dump::DumpTriggerConfig::new();
502        configure(&mut config);
503        let (mut trigger, rx) = crate::dump::channel();
504        if let Some(window) = config.debounce_window() {
505            trigger = trigger.with_debounce(window);
506        }
507        self.trigger = Some(rx);
508        self.pending_dump_trigger = Some(trigger);
509        self
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::buffer::{DiskBuffer, MemoryBuffer};
517    use crate::source::FlushContext;
518    use dial9_trace_format::TraceEvent;
519    use dial9_trace_format::decoder::Decoder;
520    use std::path::{Path, PathBuf};
521    use std::time::Duration;
522
523    #[derive(Debug, serde::Deserialize, TraceEvent)]
524    struct TestEvent {
525        #[traceevent(timestamp)]
526        timestamp_ns: u64,
527        value: u64,
528    }
529
530    /// A `Source` that emits one `TestEvent` on its first flush.
531    struct OnceSource {
532        emitted: bool,
533        value: u64,
534    }
535
536    impl Source for OnceSource {
537        fn flush(&mut self, ctx: &FlushContext<'_>) {
538            if !self.emitted {
539                self.emitted = true;
540                ctx.record_event(&TestEvent {
541                    timestamp_ns: clock::clock_monotonic_ns(),
542                    value: self.value,
543                });
544            }
545        }
546        fn name(&self) -> &'static str {
547            "once"
548        }
549    }
550
551    fn sealed_segment(dir: &Path) -> PathBuf {
552        std::fs::read_dir(dir)
553            .expect("trace dir readable")
554            .filter_map(|e| e.ok().map(|e| e.path()))
555            .find(|p| {
556                let name = p.file_name().unwrap().to_string_lossy();
557                name.ends_with(".bin") && !name.ends_with(".active")
558            })
559            .expect("a sealed .bin segment")
560    }
561
562    fn decoded_test_values(bytes: &[u8]) -> Vec<u64> {
563        let mut decoder = Decoder::new(bytes).expect("valid trace header");
564        let mut values = Vec::new();
565        decoder
566            .for_each_event(|raw| {
567                if raw.name == "TestEvent" {
568                    let event: TestEvent = raw.deserialize().expect("TestEvent decodes");
569                    values.push(event.value);
570                }
571            })
572            .expect("decode events");
573        values
574    }
575
576    /// A registered `Source` records to a real trace file without an async
577    /// runtime. The final flush on `graceful_shutdown` runs the source.
578    #[test]
579    fn records_source_events_to_disk() {
580        let dir = tempfile::tempdir().expect("tempdir");
581        let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
582
583        let recorder = recorder(writer)
584            .segment_metadata([("service".to_string(), "recorder-test".to_string())])
585            .source(OnceSource {
586                emitted: false,
587                value: 7,
588            })
589            .build();
590        recorder.graceful_shutdown(Duration::ZERO);
591
592        let bytes = std::fs::read(sealed_segment(dir.path())).expect("read segment");
593        assert!(
594            decoded_test_values(&bytes).contains(&7),
595            "the source's event should round-trip through the trace file"
596        );
597    }
598
599    /// `build()` starts recording.
600    #[test]
601    fn build_starts_recording() {
602        let writer = MemoryBuffer::new(1 << 20).expect("writer");
603        let recorder = recorder(writer).build();
604        assert!(
605            recorder.shared().expect("live recorder").is_enabled(),
606            "build() must start recording"
607        );
608    }
609
610    /// `paused()` builds a live recorder that is not yet recording.
611    #[test]
612    fn paused_build_waits_for_enable() {
613        let writer = MemoryBuffer::new(1 << 20).expect("writer");
614        let recorder = recorder(writer).paused().build();
615        assert!(
616            !recorder.shared().expect("live recorder").is_enabled(),
617            "paused() must leave recording off"
618        );
619        // The handle tells the truth: connected but paused reports disabled.
620        assert!(
621            !recorder.handle().is_enabled(),
622            "a paused handle must report disabled"
623        );
624        assert!(
625            recorder.handle().shared().is_some(),
626            "a paused handle is still connected"
627        );
628        recorder.enable();
629        assert!(
630            recorder.shared().expect("live recorder").is_enabled(),
631            "recording on after enable()"
632        );
633        assert!(
634            recorder.handle().is_enabled(),
635            "handle enabled after enable()"
636        );
637    }
638
639    /// `on_recording_start` hooks run once, with the handle, when recording
640    /// starts — at `build()`, or at `enable()` when the build was paused.
641    #[test]
642    fn on_recording_start_runs_once_when_recording_starts() {
643        use std::sync::Arc as StdArc;
644        use std::sync::atomic::{AtomicUsize, Ordering};
645
646        let runs = StdArc::new(AtomicUsize::new(0));
647        let runs_hook = StdArc::clone(&runs);
648        let live = recorder(MemoryBuffer::new(1 << 20).expect("writer"))
649            .on_recording_start(move |_handle| {
650                runs_hook.fetch_add(1, Ordering::SeqCst);
651            })
652            .build();
653        assert_eq!(runs.load(Ordering::SeqCst), 1, "hook runs at build");
654        live.enable();
655        assert_eq!(runs.load(Ordering::SeqCst), 1, "hook runs at most once");
656        drop(live);
657
658        let paused_runs = StdArc::new(AtomicUsize::new(0));
659        let paused_hook = StdArc::clone(&paused_runs);
660        let paused = recorder(MemoryBuffer::new(1 << 20).expect("writer"))
661            .on_recording_start(move |_handle| {
662                paused_hook.fetch_add(1, Ordering::SeqCst);
663            })
664            .paused()
665            .build();
666        assert_eq!(
667            paused_runs.load(Ordering::SeqCst),
668            0,
669            "paused build must not run the hook yet"
670        );
671        paused.enable();
672        assert_eq!(paused_runs.load(Ordering::SeqCst), 1, "hook runs on enable");
673    }
674
675    /// Pipeline: `.pipe()` spawns the background worker for a runtime-agnostic
676    /// recorder, and it processes the sealed segment on shutdown.
677    #[cfg(feature = "pipeline")]
678    #[test]
679    fn pipe_runs_the_background_worker() {
680        use crate::pipeline::{ProcessError, SegmentData, SegmentProcessor};
681        use std::future::Future;
682        use std::pin::Pin;
683        use std::sync::Arc as StdArc;
684        use std::sync::atomic::{AtomicUsize, Ordering};
685
686        #[derive(Debug)]
687        struct CountingProcessor(StdArc<AtomicUsize>);
688        impl SegmentProcessor for CountingProcessor {
689            fn name(&self) -> &'static str {
690                "Counting"
691            }
692            fn process(
693                &mut self,
694                data: SegmentData,
695            ) -> Pin<Box<dyn Future<Output = Result<SegmentData, ProcessError>> + Send + '_>>
696            {
697                self.0.fetch_add(1, Ordering::SeqCst);
698                Box::pin(async move { Ok(data) })
699            }
700        }
701
702        let dir = tempfile::tempdir().expect("tempdir");
703        let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
704        let processed = StdArc::new(AtomicUsize::new(0));
705
706        let recorder = recorder(writer)
707            .source(OnceSource {
708                emitted: false,
709                value: 11,
710            })
711            .pipe(CountingProcessor(StdArc::clone(&processed)))
712            .build();
713        recorder.graceful_shutdown(Duration::from_secs(5));
714
715        assert!(
716            processed.load(Ordering::SeqCst) >= 1,
717            "the background worker should process the sealed segment"
718        );
719    }
720
721    #[test]
722    fn segment_metadata_merges_last_key_wins() {
723        let mut md = vec![
724            ("service".to_string(), "checkout".to_string()),
725            ("region".to_string(), "us-east-1".to_string()),
726        ];
727        super::merge_segment_metadata(
728            &mut md,
729            [
730                ("region".to_string(), "eu-west-1".to_string()),
731                ("bucket".to_string(), "traces".to_string()),
732            ],
733        );
734
735        assert!(md.contains(&("service".to_string(), "checkout".to_string())));
736        assert!(md.contains(&("region".to_string(), "eu-west-1".to_string())));
737        assert!(md.contains(&("bucket".to_string(), "traces".to_string())));
738        assert!(
739            !md.iter().any(|(k, v)| k == "region" && v == "us-east-1"),
740            "the colliding key's old value must be gone"
741        );
742    }
743    /// Default pipeline behavior: source-requested stages run automatically,
744    /// then compression and write-back.
745    #[cfg(feature = "pipeline")]
746    #[test]
747    fn source_stage_joins_the_default_pipeline() {
748        use crate::pipeline::{ProcessError, SegmentData, SegmentProcessor};
749        use std::future::Future;
750        use std::pin::Pin;
751        use std::sync::Arc as StdArc;
752        use std::sync::atomic::{AtomicUsize, Ordering};
753
754        struct MarkerStage(StdArc<AtomicUsize>);
755        impl SegmentProcessor for MarkerStage {
756            fn name(&self) -> &'static str {
757                "Marker"
758            }
759            fn process(
760                &mut self,
761                data: SegmentData,
762            ) -> Pin<Box<dyn Future<Output = Result<SegmentData, ProcessError>> + Send + '_>>
763            {
764                self.0.fetch_add(1, Ordering::SeqCst);
765                Box::pin(async move { Ok(data) })
766            }
767        }
768
769        /// A source that contributes a pipeline stage, as the CPU profiler does.
770        struct StagedSource(StdArc<AtomicUsize>);
771        impl Source for StagedSource {
772            fn flush(&mut self, _ctx: &FlushContext<'_>) {}
773            fn name(&self) -> &'static str {
774                "staged"
775            }
776            fn segment_processor(&mut self) -> Option<Box<dyn SegmentProcessor>> {
777                Some(Box::new(MarkerStage(StdArc::clone(&self.0))))
778            }
779        }
780
781        let dir = tempfile::tempdir().expect("tempdir");
782        let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
783        let ran = StdArc::new(AtomicUsize::new(0));
784
785        let recorder = recorder(writer)
786            .source(StagedSource(StdArc::clone(&ran)))
787            .source(OnceSource {
788                emitted: false,
789                value: 3,
790            })
791            .build();
792        recorder.graceful_shutdown(Duration::from_secs(5));
793
794        assert!(
795            ran.load(Ordering::SeqCst) >= 1,
796            "the source's stage should run in the default pipeline"
797        );
798        let gzipped = std::fs::read_dir(dir.path())
799            .expect("trace dir readable")
800            .filter_map(|e| e.ok().map(|e| e.path()))
801            .any(|p| p.to_string_lossy().ends_with(".bin.gz"));
802        assert!(gzipped, "the default pipeline compresses and writes back");
803    }
804
805    /// With no stages and no uploader, the worker does not spawn.
806    #[cfg(feature = "pipeline")]
807    #[test]
808    fn default_pipeline_is_empty_without_stages() {
809        let dir = tempfile::tempdir().expect("tempdir");
810        let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
811
812        let recorder = recorder(writer)
813            .source(OnceSource {
814                emitted: false,
815                value: 5,
816            })
817            .build();
818        recorder.graceful_shutdown(Duration::from_secs(5));
819
820        let compressed = std::fs::read_dir(dir.path())
821            .expect("trace dir readable")
822            .filter_map(|e| e.ok().map(|e| e.path()))
823            .any(|p| p.to_string_lossy().ends_with(".gz"));
824        assert!(
825            !compressed,
826            "no stages means no worker, so nothing is gzipped"
827        );
828        // The trace itself is still readable, straight off the writer.
829        let bytes = std::fs::read(sealed_segment(dir.path())).expect("read segment");
830        assert_eq!(decoded_test_values(&bytes), vec![5]);
831    }
832
833    /// A terminal stage ships segments elsewhere, so the worker should run even
834    /// when no source contributes a stage. It replaces write-back.
835    #[cfg(feature = "pipeline")]
836    #[test]
837    fn terminal_processor_replaces_write_back() {
838        use crate::pipeline::{ProcessError, SegmentData, SegmentProcessor};
839        use std::future::Future;
840        use std::pin::Pin;
841        use std::sync::Arc as StdArc;
842        use std::sync::atomic::{AtomicUsize, Ordering};
843
844        struct Uploader(StdArc<AtomicUsize>);
845        impl SegmentProcessor for Uploader {
846            fn name(&self) -> &'static str {
847                "Uploader"
848            }
849            fn process(
850                &mut self,
851                data: SegmentData,
852            ) -> Pin<Box<dyn Future<Output = Result<SegmentData, ProcessError>> + Send + '_>>
853            {
854                self.0.fetch_add(1, Ordering::SeqCst);
855                Box::pin(async move { Ok(data) })
856            }
857        }
858
859        let dir = tempfile::tempdir().expect("tempdir");
860        let writer = DiskBuffer::single_file(dir.path().join("trace.bin")).expect("writer");
861        let uploaded = StdArc::new(AtomicUsize::new(0));
862
863        let recorder = recorder(writer)
864            .source(OnceSource {
865                emitted: false,
866                value: 9,
867            })
868            .terminal_processor(Uploader(StdArc::clone(&uploaded)))
869            .build();
870        recorder.graceful_shutdown(Duration::from_secs(5));
871
872        assert!(
873            uploaded.load(Ordering::SeqCst) >= 1,
874            "the terminal stage runs on its own"
875        );
876        let written_back = std::fs::read_dir(dir.path())
877            .expect("trace dir readable")
878            .filter_map(|e| e.ok().map(|e| e.path()))
879            .any(|p| p.to_string_lossy().ends_with(".bin.gz"));
880        assert!(!written_back, "the terminal stage takes write-back's place");
881    }
882
883    #[test]
884    fn recording_thread_hook_runs_on_dial9_threads() {
885        use std::sync::Arc as StdArc;
886        use std::sync::atomic::{AtomicUsize, Ordering};
887
888        fn build_and_shut_down<M: BufferMode + Send + 'static>(
889            writer: crate::buffer::SegmentWriter<M>,
890        ) -> (usize, usize) {
891            let started = StdArc::new(AtomicUsize::new(0));
892            let stopped = StdArc::new(AtomicUsize::new(0));
893            let (s, t) = (StdArc::clone(&started), StdArc::clone(&stopped));
894
895            let recorder =
896                RecorderSourceExt::on_recording_thread_start(recorder(writer), move || {
897                    s.fetch_add(1, Ordering::SeqCst);
898                    let t = StdArc::clone(&t);
899                    move || {
900                        t.fetch_add(1, Ordering::SeqCst);
901                    }
902                })
903                .build();
904            recorder.graceful_shutdown(Duration::from_secs(5));
905
906            (
907                started.load(Ordering::SeqCst),
908                stopped.load(Ordering::SeqCst),
909            )
910        }
911
912        let (started, stopped) = build_and_shut_down(MemoryBuffer::new(64 * 1024).unwrap());
913        assert!(
914            started >= 1,
915            "the hook should run on the flush thread, ran {started} times"
916        );
917        assert_eq!(
918            started, stopped,
919            "every thread that ran the hook should run its teardown"
920        );
921    }
922}
923
924#[cfg(all(test, not(feature = "test-util")))]
925mod single_recorder_tests {
926    use super::*;
927    use crate::buffer::MemoryBuffer;
928
929    #[test]
930    fn a_second_recorder_in_the_process_is_refused() {
931        let first = recorder(MemoryBuffer::new(1 << 20).unwrap()).build();
932        assert!(first.handle().is_connected(), "the first one records");
933
934        let second = recorder(MemoryBuffer::new(1 << 20).unwrap()).build();
935        assert!(
936            !second.handle().is_connected(),
937            "the process already has a recorder, so this one is inert"
938        );
939
940        drop(second);
941        drop(first);
942
943        let after = recorder(MemoryBuffer::new(1 << 20).unwrap()).build();
944        assert!(
945            after.handle().is_connected(),
946            "the slot frees up once the holder stops"
947        );
948    }
949}