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 a pre-built
17//! [`SharedState`](crate::shared_state::SharedState) with sources already
18//! registered. The Tokio integration reuses the same builder.
19
20use 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
28/// A reusable per-thread hook: run on each recording thread, returning a
29/// teardown closure. Reusable (`Fn`) because both the flush thread and the
30/// background worker each need a fresh `FnOnce`.
31type 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
37/// Merge `entries` into `existing`: on a key collision the incoming value wins.
38/// Matches the writer's segment-metadata merge, so builder-side metadata
39/// accumulates across the core and tokio layers.
40pub(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
49/// Begin building a recorder backed by `writer`.
50///
51/// Register data sources with [`RecorderBuilder::source`], then
52/// [`build`](RecorderBuilder::build), which starts recording.
53pub fn recorder<M: BufferMode>(writer: SegmentWriter<M>) -> RecorderBuilder<M> {
54    builder_with(Some(writer))
55}
56
57/// Begin building a recorder backed by `writer`, or a writer-free disabled one
58/// when the writer could not be created.
59///
60/// Configure it exactly like [`recorder`]: register sources, set a pipeline,
61/// then [`build`](RecorderBuilder::build). When the writer failed, every one of
62/// those is retained but inert, and `build` yields the same recorder
63/// [`recorder_disabled`] does. A bad trace path therefore costs telemetry, not
64/// the process.
65pub 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
102/// A recorder with recording permanently disabled: no flush thread, no sources,
103/// and [`handle`](crate::recording::Recorder::handle) returns a disabled handle.
104///
105/// Attaching Tokio to it is a no-op and `enable`/`graceful_shutdown` do nothing,
106/// so it is the "telemetry off" fallback for `#[dial9::main]` and
107/// `recorder_or_disabled`: application code runs unchanged, recording nothing.
108pub fn recorder_disabled() -> crate::recording::Recorder {
109    crate::recording::Recorder::new(crate::handle::Dial9Handle::disabled(), None)
110}
111
112/// Assemble dial9's default pipeline: source-requested stages
113/// (for example symbolization), then compression, then a terminal stage —
114/// uploader if configured, otherwise disk write-back.
115///
116/// Returns empty when there is no source stage and no uploader; in that case
117/// no worker is spawned.
118#[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/// Builder for a runtime-agnostic [`Recorder`]. See [`recorder`].
140#[must_use = "call `.build()` to start recording"]
141pub struct RecorderBuilder<M: BufferMode = Disk> {
142    /// `None` when the writer could not be created (see
143    /// [`recorder_or_disabled`]); `build` then yields a disabled recorder.
144    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    /// The segment-processing pipeline. `Some` runs exactly these processors,
151    /// `None` assembles dial9's default from the registered sources at build.
152    #[cfg(feature = "pipeline")]
153    pipeline: Option<Vec<Box<dyn crate::pipeline::SegmentProcessor>>>,
154    /// Final stage of the default pipeline, replacing write-back (the S3
155    /// uploader sets it). Applied at build, so it does not depend on the order
156    /// the builder was called in.
157    #[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    /// Dump-trigger sender, installed on the shared state at build so
164    /// `Dial9Handle::dump_trigger` can reach it.
165    #[cfg(feature = "pipeline")]
166    pending_dump_trigger: Option<crate::dump::DumpTrigger>,
167    /// Whether [`build`](RecorderBuilder::build) starts recording. See
168    /// [`paused`](RecorderBuilder::paused).
169    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    /// Register a [`Source`] drained by the flush thread each cycle.
182    pub fn source(mut self, source: impl Source + 'static) -> Self {
183        self.sources.push(Box::new(source));
184        self
185    }
186
187    /// Names of the registered sources, in registration order.
188    pub fn source_names(&self) -> impl Iterator<Item = &str> + '_ {
189        self.sources.iter().map(|s| s.name())
190    }
191
192    /// The writer's per-process namespace boot id, or `None` before
193    /// [`set_namespace`](SegmentWriter::set_namespace) has run.
194    pub fn writer_boot_id(&self) -> Option<&str> {
195        self.writer.as_ref()?.boot_id()
196    }
197
198    /// Static metadata written into every rotated segment header. Merged across
199    /// calls (and across the tokio layer); on a key collision the later value wins.
200    ///
201    /// # Examples
202    ///
203    /// ```
204    /// use dial9_core::buffer::MemoryBuffer;
205    /// use dial9_core::recorder::recorder;
206    ///
207    /// let recorder = recorder(MemoryBuffer::new(1024 * 1024)?)
208    ///     .segment_metadata([("service".into(), "checkout".into())])
209    ///     .segment_metadata([("environment".into(), "production".into())])
210    ///     .segment_metadata([("service".into(), "payments".into())])
211    ///     .build();
212    ///
213    /// // Metadata is merged across calls. The third call overrides `service`
214    /// // from the first, so its value is `payments`.
215    /// # recorder.graceful_shutdown(std::time::Duration::ZERO);
216    /// # Ok::<(), std::io::Error>(())
217    /// ```
218    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    /// Metrics sink for the flush (and, with `pipeline`, worker) threads.
224    /// Defaults to discarding flush metrics.
225    pub fn metrics_sink(mut self, sink: metrique::writer::BoxEntrySink) -> Self {
226        self.metrics_sink = Some(sink);
227        self
228    }
229
230    /// Hook run once on every recording thread (flush thread and background
231    /// worker) before it starts, returning a teardown run when it stops. Use it
232    /// to register/unregister the thread with a profiler. Defaults to a no-op.
233    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    /// Start the recorder and begin recording.
243    ///
244    /// Chain [`paused`](Self::paused) beforehand to build without recording, then
245    /// start it later with [`Recorder::enable`].
246    ///
247    /// Yields a disabled recorder when the writer could not be created (see
248    /// [`recorder_or_disabled`]); the sources and pipeline configured on the way
249    /// here are simply never started.
250    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        // Install the on-demand dump trigger so `Dial9Handle::dump_trigger` can
259        // reach it (paired with the `trigger` receiver handed to the worker).
260        #[cfg(feature = "pipeline")]
261        if let Some(trigger) = self.pending_dump_trigger {
262            shared.set_dump_trigger(trigger);
263        }
264
265        // Sync any `boot_id` metadata to the writer's per-process namespace, so a
266        // trace's identity matches its on-disk `{boot_id}/` directory (and its
267        // S3 keys).
268        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        // Collect the stages the sources ask for before they move into the
284        // shared state; only the default pipeline uses them.
285        #[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        // The worker borrows `&writer`, so it must be spawned before the writer
302        // moves into `Recorder::start`.
303        #[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    /// Build without recording. [`Recorder::enable`] starts it later.
346    ///
347    /// Use for a recorder that should exist but stay quiet until something turns
348    /// it on; for permanently-off telemetry prefer [`recorder_disabled`], which
349    /// allocates no writer at all.
350    pub fn paused(mut self) -> Self {
351        self.enabled = false;
352        self
353    }
354}
355
356// TODO(tokio-as-source): now that tokio attaches to a built `Recorder`, this
357// trait has a single implementor. Fold it into inherent `RecorderBuilder`
358// methods once the `RecorderPerfExt` blanket impl can be reworked.
359/// A builder that can register [`Source`]s.
360///
361/// Implemented by [`RecorderBuilder`]; the `.with_*()` perf-source sugar is
362/// built on top of it.
363pub trait RecorderSourceExt: Sized {
364    /// Register a [`Source`] with the underlying recording recorder.
365    fn source(self, source: impl Source + 'static) -> Self;
366
367    /// Register a hook run once, with the live [`Dial9Handle`], when the recorder
368    /// starts recording.
369    fn on_recording_start(self, hook: impl FnOnce(&Dial9Handle) + Send + 'static) -> Self;
370
371    /// Register a callback that dial9 invokes on the flush thread at the config's
372    /// interval to emit custom events. Sugar for [`source`](Self::source) with a
373    /// [`CustomEventsSource`](crate::custom_events::CustomEventsSource). Not
374    /// tokio-coupled — works on the plain recorder and the tokio builder.
375    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    /// Append a segment processor (compress, symbolize, upload, write-back),
404    /// replacing dial9's default pipeline with your own stages.
405    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    /// Set the full processor pipeline at once, replacing dial9's default and
413    /// anything added with [`pipe`](Self::pipe). Use this when you already have
414    /// a built list, or `pipe` to append incrementally.
415    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    /// Replace write-back as the last stage of the default pipeline, so sealed
424    /// segments are shipped elsewhere instead of written back to disk. This
425    /// also makes processing meaningful even with no other stage, so the worker
426    /// still runs.
427    ///
428    /// Ignored when a custom pipeline is set. The S3 uploader is wired up this
429    /// way.
430    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    /// How often the background worker polls for sealed segments.
439    pub fn worker_poll_interval(mut self, interval: std::time::Duration) -> Self {
440        self.worker_poll_interval = Some(interval);
441        self
442    }
443
444    /// Trigger receiver switching the worker into on-demand dump mode; see
445    /// [`crate::dump`]. `None` keeps continuous mode.
446    pub fn trigger(mut self, trigger: crate::dump::DumpRx) -> Self {
447        self.trigger = Some(trigger);
448        self
449    }
450
451    /// Enable on-demand dump mode: the background worker runs the pipeline only
452    /// when a dump is requested through the
453    /// [`DumpTrigger`](crate::dump::DumpTrigger), reachable from any recording
454    /// thread via [`Dial9Handle::dump_trigger`](crate::handle::Dial9Handle::dump_trigger).
455    /// Pass `|_| {}` for the default, or `|t| { t.debounce(window); }` to
456    /// coalesce bursts.
457    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    /// A `Source` that emits one `TestEvent` on its first flush.
491    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    /// A registered `Source` records to a real trace file without an async
537    /// runtime. The final flush on `graceful_shutdown` runs the source.
538    #[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    /// `build()` starts recording.
560    #[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    /// `paused()` builds a live recorder that is not yet recording.
571    #[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        // The handle tells the truth: connected but paused reports disabled.
580        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    /// `on_recording_start` hooks run once, with the handle, when recording
600    /// starts — at `build()`, or at `enable()` when the build was paused.
601    #[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    /// Pipeline: `.pipe()` spawns the background worker for a runtime-agnostic
635    /// recorder, and it processes the sealed segment on shutdown.
636    #[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    /// Default pipeline behavior: source-requested stages run automatically,
703    /// then compression and write-back.
704    #[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        /// A source that contributes a pipeline stage, as the CPU profiler does.
729        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    /// With no stages and no uploader, the worker does not spawn.
765    #[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        // The trace itself is still readable, straight off the writer.
788        let bytes = std::fs::read(sealed_segment(dir.path())).expect("read segment");
789        assert_eq!(decoded_test_values(&bytes), vec![5]);
790    }
791
792    /// A terminal stage ships segments elsewhere, so the worker should run even
793    /// when no source contributes a stage. It replaces write-back.
794    #[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}