Skip to main content

rtb_telemetry/
sink.rs

1//! [`TelemetrySink`] trait and the three built-in implementations.
2
3use std::path::PathBuf;
4use std::sync::{Arc, Mutex};
5
6use async_trait::async_trait;
7use tokio::io::AsyncWriteExt;
8
9use crate::error::TelemetryError;
10use crate::event::Event;
11
12/// Backend-agnostic sink for [`Event`] values.
13#[async_trait]
14pub trait TelemetrySink: Send + Sync + 'static {
15    /// Emit a single event. Sinks SHOULD be idempotent on the event —
16    /// retries or batching live in wrapper sinks (v0.2).
17    async fn emit(&self, event: &Event) -> Result<(), TelemetryError>;
18
19    /// Flush any buffered events. Default impl is a no-op.
20    async fn flush(&self) -> Result<(), TelemetryError> {
21        Ok(())
22    }
23}
24
25// =====================================================================
26// NoopSink — drops everything silently.
27// =====================================================================
28
29/// Drops every event silently. Used when collection is disabled and
30/// as the default when a tool ships telemetry support but hasn't
31/// configured a real sink.
32#[derive(Debug, Default, Clone, Copy)]
33pub struct NoopSink;
34
35#[async_trait]
36impl TelemetrySink for NoopSink {
37    async fn emit(&self, _event: &Event) -> Result<(), TelemetryError> {
38        Ok(())
39    }
40}
41
42// =====================================================================
43// MemorySink — in-memory Vec, useful for tests.
44// =====================================================================
45
46/// In-memory sink for tests. Events are appended in emit order and
47/// inspectable via [`MemorySink::snapshot`].
48#[derive(Debug, Default, Clone)]
49pub struct MemorySink {
50    inner: Arc<Mutex<Vec<Event>>>,
51}
52
53impl MemorySink {
54    /// Construct a fresh empty sink.
55    #[must_use]
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// Return a clone of every recorded event.
61    #[must_use]
62    pub fn snapshot(&self) -> Vec<Event> {
63        self.inner.lock().map(|v| v.clone()).unwrap_or_default()
64    }
65
66    /// How many events have been recorded so far.
67    #[must_use]
68    pub fn len(&self) -> usize {
69        self.inner.lock().map_or(0, |v| v.len())
70    }
71
72    /// `true` when no events have been recorded.
73    #[must_use]
74    pub fn is_empty(&self) -> bool {
75        self.len() == 0
76    }
77}
78
79#[async_trait]
80impl TelemetrySink for MemorySink {
81    async fn emit(&self, event: &Event) -> Result<(), TelemetryError> {
82        if let Ok(mut v) = self.inner.lock() {
83            v.push(event.clone());
84        }
85        Ok(())
86    }
87}
88
89// =====================================================================
90// FileSink — newline-delimited JSON to disk.
91// =====================================================================
92
93/// Appends events as JSON Lines to a file. Parent directories are
94/// created on demand.
95///
96/// # Concurrency and line integrity
97///
98/// Concurrent `emit` calls on the same `FileSink` are serialised via
99/// a shared `tokio::sync::Mutex`. This is required for JSONL
100/// correctness: on POSIX, `O_APPEND` only guarantees atomicity for
101/// individual `write()` calls up to `PIPE_BUF` (4 KiB on Linux). An
102/// event whose serialised form exceeds that bound — plausible for
103/// events with many attrs — would interleave at the byte level with
104/// concurrent writers and produce malformed JSONL.
105///
106/// Cross-**process** writers (two `FileSink`s in different processes
107/// targeting the same file) remain interleaving-safe only up to
108/// `PIPE_BUF`. Don't do that; use per-process files and aggregate
109/// elsewhere.
110///
111/// Batching lives in a wrapper sink when we need it (v0.2).
112#[derive(Debug, Clone)]
113pub struct FileSink {
114    path: PathBuf,
115    // Serialises concurrent `emit` calls. Shared across `Clone`s of
116    // the same `FileSink` so multiple handles to the same path also
117    // serialise correctly.
118    gate: Arc<tokio::sync::Mutex<()>>,
119}
120
121impl FileSink {
122    /// Construct a sink targeting `path`. The file is not touched
123    /// until the first `emit`; parent directories are created on
124    /// demand at that point.
125    #[must_use]
126    pub fn new(path: impl Into<PathBuf>) -> Self {
127        Self { path: path.into(), gate: Arc::new(tokio::sync::Mutex::new(())) }
128    }
129}
130
131#[async_trait]
132impl TelemetrySink for FileSink {
133    async fn emit(&self, event: &Event) -> Result<(), TelemetryError> {
134        // Redact `args` / `err_msg` before serialisation — the file
135        // is the first out-of-process surface.
136        let redacted = event.redacted();
137        // Serialise the line outside the critical section — parsing
138        // the event is the expensive part.
139        let mut line =
140            serde_json::to_string(&redacted).map_err(|e| TelemetryError::Serde(e.to_string()))?;
141        line.push('\n');
142
143        // Hold the write gate across parent-dir creation + open +
144        // write + flush so concurrent `emit` calls never interleave
145        // bytes.
146        let _guard = self.gate.lock().await;
147
148        if let Some(parent) = self.path.parent() {
149            if !parent.as_os_str().is_empty() {
150                tokio::fs::create_dir_all(parent).await?;
151            }
152        }
153
154        let mut f =
155            tokio::fs::OpenOptions::new().create(true).append(true).open(&self.path).await?;
156        f.write_all(line.as_bytes()).await?;
157        f.flush().await?;
158        Ok(())
159    }
160}