dial9_core/recording.rs
1use crate::buffer::{BufferMode, SegmentWriter};
2use crate::flush_loop::run_flush_loop;
3use crate::handle::{ControlCommand, Dial9Handle, InstallGlobalHandleError};
4use crate::primitives::sync::{Arc, Mutex};
5use crate::primitives::{sync::mpsc, thread::JoinHandle};
6use crate::recorder::SoleRecorderGuard;
7use crate::shared_state::SharedState;
8use std::time::Duration;
9
10/// The background worker thread and its stop signal.
11///
12/// Present only when a segment-processing pipeline is configured.
13#[cfg(feature = "pipeline")]
14pub(crate) struct WorkerHandle {
15 shutdown: Option<tokio::sync::oneshot::Sender<Duration>>,
16 thread: Option<JoinHandle<()>>,
17}
18
19#[cfg(feature = "pipeline")]
20impl WorkerHandle {
21 /// Wrap the worker's shutdown sender and join handle.
22 pub(crate) fn new(
23 shutdown: tokio::sync::oneshot::Sender<Duration>,
24 thread: JoinHandle<()>,
25 ) -> Self {
26 Self {
27 shutdown: Some(shutdown),
28 thread: Some(thread),
29 }
30 }
31}
32
33/// Owns the recording state: the [`Dial9Handle`], the flush thread, and (with
34/// the `pipeline` feature) the background worker.
35///
36/// This is an RAII guard: dropping it flushes remaining events, seals the final
37/// segment, and stops the worker. For a bounded drain of the background worker
38/// (symbolize, compress, upload) call [`graceful_shutdown`](Self::graceful_shutdown)
39/// instead.
40pub struct Recorder {
41 handle: Dial9Handle,
42 flush_thread: Option<JoinHandle<()>>,
43 /// Hooks run once, with the handle, on the first `enable()`.
44 recording_start_hooks: Mutex<Vec<RecordingStartHook>>,
45 /// Held while this is the process's recorder. Dropping it frees the slot.
46 sole_recorder: Option<SoleRecorderGuard>,
47 #[cfg(feature = "pipeline")]
48 worker: Option<WorkerHandle>,
49}
50
51impl std::fmt::Debug for Recorder {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 f.debug_struct("Recorder")
54 .field("enabled", &self.handle.is_enabled())
55 .field("recording", &self.flush_thread.is_some())
56 .finish_non_exhaustive()
57 }
58}
59
60/// A hook run once, with the live [`Dial9Handle`], when the recorder first
61/// enables recording.
62pub type RecordingStartHook = Box<dyn FnOnce(&Dial9Handle) + Send>;
63
64impl Recorder {
65 /// Create a recorder from an existing handle and flush thread.
66 pub(crate) fn new(handle: Dial9Handle, flush_thread: Option<JoinHandle<()>>) -> Self {
67 Self {
68 handle,
69 flush_thread,
70 recording_start_hooks: Mutex::new(Vec::new()),
71 sole_recorder: None,
72 #[cfg(feature = "pipeline")]
73 worker: None,
74 }
75 }
76
77 /// Hold the process's recorder slot for this recorder's lifetime.
78 pub(crate) fn hold_process(&mut self, guard: crate::recorder::SoleRecorderGuard) {
79 self.sole_recorder = Some(guard);
80 }
81
82 /// Install the one-shot hooks to run on the first `enable()`.
83 pub(crate) fn set_recording_start_hooks(&self, hooks: Vec<RecordingStartHook>) {
84 *self.recording_start_hooks.lock().unwrap() = hooks;
85 }
86
87 /// Start recording over `shared`: build the recording [`Dial9Handle`], spawn
88 /// the flush thread that drains the bus into `writer`, and own its lifecycle.
89 ///
90 /// The flush-thread control channel is created and owned internally; reach
91 /// the handle via [`handle`](Self::handle).
92 ///
93 /// `thread_init` runs once on the flush thread before the loop and returns
94 /// a teardown closure run after it — use it to register/unregister the
95 /// thread with a runtime's profiler.
96 ///
97 /// Pass `None` for `flush_metrics_sink` to discard flush metrics.
98 pub(crate) fn start<M, Init, Teardown>(
99 shared: Arc<SharedState>,
100 writer: SegmentWriter<M>,
101 flush_metrics_sink: Option<metrique::writer::BoxEntrySink>,
102 thread_init: Init,
103 ) -> Self
104 where
105 M: BufferMode + Send + 'static,
106 Init: FnOnce() -> Teardown + Send + 'static,
107 Teardown: FnOnce(),
108 {
109 let (control_tx, control_rx) = mpsc::sync_channel(1);
110 let handle = Dial9Handle::enabled(shared.clone(), control_tx);
111 let flush_metrics_sink =
112 flush_metrics_sink.unwrap_or_else(metrique::writer::sink::DevNullSink::boxed);
113 let flush_thread = crate::primitives::thread::spawn_named("dial9-flush", move || {
114 // The flush thread is latency-tolerant; lower its priority.
115 #[cfg(target_os = "linux")]
116 // SAFETY: nice() is a simple syscall with no memory-safety
117 // implications; lowering priority is always permitted unprivileged.
118 unsafe {
119 let _ = libc::nice(10);
120 }
121 let teardown = thread_init();
122 run_flush_loop(control_rx, &shared, &flush_metrics_sink, writer);
123 teardown();
124 });
125 Self::new(handle, Some(flush_thread))
126 }
127
128 /// The recording handle for this recorder.
129 pub fn handle(&self) -> &Dial9Handle {
130 &self.handle
131 }
132
133 /// Publish this recorder's handle as the process-global one.
134 ///
135 /// When set, [`Dial9Handle::current`] resolves on every thread in the
136 /// process. When not set, it resolves only on threads a runtime integration
137 /// has installed a handle on.
138 ///
139 /// Returns [`InstallGlobalHandleError`] and changes nothing if another handle
140 /// is already installed: two live globals would split one process's events
141 /// across two traces. A recorder clears its own when it stops, so a later
142 /// install succeeds.
143 ///
144 /// ```no_run
145 /// use dial9_core::buffer::MemoryBuffer;
146 /// use dial9_core::handle::Dial9Handle;
147 /// use dial9_core::recorder::recorder;
148 /// use dial9_trace_format::TraceEvent;
149 ///
150 /// #[derive(TraceEvent)]
151 /// struct Tick {
152 /// #[traceevent(timestamp)]
153 /// timestamp_ns: u64,
154 /// }
155 ///
156 /// let rec = recorder(MemoryBuffer::new(1 << 20)?).build();
157 /// rec.install_global_handle()?;
158 ///
159 /// std::thread::spawn(|| {
160 /// // reachable here, with no handle plumbed in
161 /// Dial9Handle::current().record_event(Tick { timestamp_ns: 0 });
162 /// });
163 /// # Ok::<_, Box<dyn std::error::Error>>(())
164 /// ```
165 pub fn install_global_handle(&self) -> Result<(), InstallGlobalHandleError> {
166 crate::handle::set_global_handle(self.handle.clone())
167 }
168
169 /// Attach the background worker to this recorder, so its lifecycle is tied
170 /// to the recorder's (drained on `graceful_shutdown`, stopped on drop).
171 #[cfg(feature = "pipeline")]
172 pub(crate) fn attach_worker(&mut self, worker: WorkerHandle) {
173 self.worker = Some(worker);
174 }
175
176 crate::test_util_pub! {
177 /// The shared recording state.
178 fn shared(&self) -> Option<&Arc<SharedState>> {
179 self.handle.shared()
180 }
181 }
182
183 /// Monotonic start time of the recorder in nanoseconds.
184 pub fn start_time(&self) -> Option<u64> {
185 self.shared().map(|s| s.start_time_ns())
186 }
187
188 /// Enable recording.
189 pub fn enable(&self) {
190 self.handle.enable();
191 // Run the one-shot start hooks now that the handle is live and
192 // recording. Draining leaves them run-once across repeated enables.
193 let hooks = std::mem::take(&mut *self.recording_start_hooks.lock().unwrap());
194 for hook in hooks {
195 hook(&self.handle);
196 }
197 }
198
199 /// Disable recording.
200 pub fn disable(&self) {
201 self.handle.disable();
202 }
203
204 /// Flush remaining events, seal the final segment, and join the flush thread.
205 ///
206 /// Call this before dropping any runtime state that owns worker threads, so
207 /// that their thread-local buffers have already been flushed to the central
208 /// collector.
209 pub(crate) fn stop_flush_thread(&mut self) {
210 // Clear the global before the blocking flush below, otherwise other threads
211 // keep resolving it and recording into buffers that nothing will drain.
212 if let Some(shared) = self.handle.shared() {
213 crate::handle::clear_global_handle_for(shared);
214 }
215
216 // Drain the calling thread's local buffer — it won't get a thread-stop
217 // hook, so any unflushed events would be lost otherwise.
218 if let Some(shared) = self.handle.shared() {
219 crate::encoder::drain_to_collector(&shared.collector);
220 }
221
222 // Tell the flush thread to do a final flush + finalize, then exit.
223 let (ack_tx, ack_rx) = mpsc::sync_channel(0);
224 if let Some(tx) = self.handle.control_tx()
225 && tx.send(ControlCommand::FinalizeAndStop(ack_tx)).is_ok()
226 {
227 let _ = ack_rx.recv();
228 }
229 if let Some(t) = self.flush_thread.take() {
230 let _ = t.join();
231 }
232
233 // Stop is permanent from here: recording off, enable() and new
234 // attaches refused. Nothing drains sources once the flush thread is
235 // gone, so release them and whatever they own.
236 if let Some(shared) = self.handle.shared() {
237 shared.mark_stopped();
238 shared.clear_sources();
239 }
240
241 // Runtime threads drop their handle in a thread-stop hook, but the
242 // thread that attached the runtime gets no such hook and would hold a
243 // handle to a stopped recorder for the rest of its life.
244 crate::handle::clear_tl_handle();
245 }
246
247 /// Flush remaining events, seal the final segment, and (with `pipeline`)
248 /// wait for the background worker to drain within `timeout`.
249 ///
250 /// Call this after any runtime that owns worker threads has been dropped, so
251 /// their thread-local buffers have already been flushed. Consumes the
252 /// recorder so `Drop` becomes a no-op.
253 ///
254 /// Failures during draining are logged.
255 pub fn graceful_shutdown(mut self, timeout: Duration) {
256 // `timeout` only bounds the worker drain, which exists under `pipeline`.
257 #[cfg(not(feature = "pipeline"))]
258 let _ = timeout;
259
260 // 1. Flush + finalize the last segment.
261 self.stop_flush_thread();
262
263 // 2. Signal the worker to drain, then join it.
264 #[cfg(feature = "pipeline")]
265 if let Some(w) = &mut self.worker {
266 if let Some(tx) = w.shutdown.take() {
267 let _ = tx.send(timeout);
268 }
269 if let Some(t) = w.thread.take()
270 && let Err(e) = t.join()
271 {
272 tracing::error!(target: "dial9", panic = ?e, "worker thread panicked during shutdown");
273 }
274 }
275 }
276}
277
278impl Drop for Recorder {
279 fn drop(&mut self) {
280 // 1. Flush + finalize. Idempotent, so a prior graceful_shutdown/stop is fine.
281 self.stop_flush_thread();
282
283 // 2. Hard shutdown: drop the sender without sending — the worker sees a
284 // closed channel and exits without draining. For a graceful drain, call
285 // graceful_shutdown() instead.
286 #[cfg(feature = "pipeline")]
287 if let Some(w) = &mut self.worker {
288 w.shutdown.take();
289 }
290 }
291}