Skip to main content

codewhale_telemetry/
buffer.rs

1//! The on-disk ring buffer, the tombstone, and the wipe.
2//!
3//! Everything lives under `$CODEWHALE_HOME/telemetry/`, created `0700`, with
4//! every file `0600`:
5//!
6//! | file | role |
7//! |---|---|
8//! | `buffer.jsonl` | one JSON event per line, awaiting a flush |
9//! | `buffer.jsonl.lock` | a **sibling** lock file; never the data file |
10//! | `dryrun.jsonl` | the sink when the endpoint resolves empty, same ring policy |
11//! | `state.json` | last version seen and last flush attempt |
12//! | `install_id.json` | the random install id |
13//! | `disabled` | the tombstone: present ⇒ nothing is appended, drained, or sent |
14//!
15//! **Appends never take a lock.** One `O_APPEND` `write(2)` under `PIPE_BUF` is
16//! atomic on every filesystem this ships to, and taking `fd_lock` here would be
17//! a *blocking* acquisition on the panic hook and the SIGINT path. `flock` is
18//! per-fd within a process, so an actor panic while holding the compaction lock
19//! would self-deadlock the hook — `catch_unwind` runs *after* the hook, so it
20//! cannot save this — and a second Codewhale process sharing `CODEWHALE_HOME`
21//! would hang Ctrl-C, breaking the second-signal contract in `main.rs`.
22//!
23//! Compaction is the only lock holder and uses `try_write()`: on contention it
24//! skips this cycle. Appenders re-open per append, so a compaction rewrite
25//! cannot leave anyone writing to a stale inode.
26
27use std::fs::{self, DirBuilder, File, OpenOptions};
28use std::io::Write as _;
29use std::path::{Path, PathBuf};
30
31use anyhow::{Context, Result};
32
33/// Newest events retained in either sink.
34pub const MAX_EVENTS: usize = 512;
35/// Byte ceiling for either sink.
36pub const MAX_BYTES: u64 = 256 * 1024;
37/// A single append must fit in one atomic `write(2)`.
38pub const MAX_LINE_BYTES: usize = 4096;
39
40/// Below this size a sink cannot possibly hold [`MAX_EVENTS`] lines, so an
41/// append skips the count probe entirely. The shortest serializable event line
42/// is well over 8 bytes, and `512 * 9 > 4096`, so this bound is safe by
43/// construction — `probe_threshold_cannot_hide_an_over_cap_buffer` pins it.
44const PROBE_BYTES: u64 = 4096;
45
46/// `buffer.jsonl` — the pending-event sink.
47#[must_use]
48pub fn buffer_path(root: &Path) -> PathBuf {
49    root.join("buffer.jsonl")
50}
51
52/// `dryrun.jsonl` — where batches go when the endpoint resolves to `None`.
53///
54/// Reached by configuring `telemetry_endpoint` empty; an unconfigured endpoint
55/// resolves to `codewhale_config::DEFAULT_TELEMETRY_ENDPOINT` instead.
56#[must_use]
57pub fn dryrun_path(root: &Path) -> PathBuf {
58    root.join("dryrun.jsonl")
59}
60
61/// `buffer.jsonl.lock` — the sibling lock file. Never the data file, and never
62/// unlinked: replacing it would leave appenders and compactors holding
63/// different inodes and serialising against nothing.
64#[must_use]
65pub fn lock_path(root: &Path) -> PathBuf {
66    root.join("buffer.jsonl.lock")
67}
68
69/// `disabled` — the tombstone.
70#[must_use]
71pub fn tombstone_path(root: &Path) -> PathBuf {
72    root.join("disabled")
73}
74
75/// `install_id.json`.
76#[must_use]
77pub fn install_id_path(root: &Path) -> PathBuf {
78    root.join("install_id.json")
79}
80
81/// `state.json`.
82#[must_use]
83pub fn state_path(root: &Path) -> PathBuf {
84    root.join("state.json")
85}
86
87/// Whether the tombstone is present.
88///
89/// Re-checked on **every** append and immediately before **every** send. This is
90/// what makes `codewhale config set telemetry false` — an external write by
91/// another process — observable to a session that is already running.
92#[must_use]
93pub fn tombstone_present(root: &Path) -> bool {
94    tombstone_path(root).exists()
95}
96
97/// Create the telemetry directory `0700`, if it is missing.
98pub fn ensure_dir(root: &Path) -> Result<()> {
99    if root.is_dir() {
100        return Ok(());
101    }
102    let mut builder = DirBuilder::new();
103    builder.recursive(true);
104    #[cfg(unix)]
105    {
106        use std::os::unix::fs::DirBuilderExt as _;
107        builder.mode(0o700);
108    }
109    builder
110        .create(root)
111        .with_context(|| format!("failed to create {}", root.display()))
112}
113
114#[cfg(unix)]
115fn secure(file: &File) -> Result<()> {
116    use std::os::unix::fs::PermissionsExt as _;
117    file.set_permissions(fs::Permissions::from_mode(0o600))
118        .context("failed to restrict telemetry file permissions")
119}
120
121#[cfg(not(unix))]
122fn secure(_file: &File) -> Result<()> {
123    Ok(())
124}
125
126/// Append one serialized event or batch to `path`.
127///
128/// Returns `None` — never an error — when the tombstone is present, when the
129/// line would not fit in one atomic write, or when any filesystem step fails.
130/// Telemetry is fail-open by construction: it never returns an error to a
131/// caller and never blocks a turn, a tool, or process exit.
132pub fn append(root: &Path, path: &Path, line: &str) -> Option<()> {
133    if tombstone_present(root) {
134        return None;
135    }
136    let bytes = line.as_bytes();
137    if bytes.is_empty() || bytes.len() + 1 > MAX_LINE_BYTES {
138        return None;
139    }
140    ensure_dir(root).ok()?;
141
142    let mut buf = Vec::with_capacity(bytes.len() + 1);
143    buf.extend_from_slice(bytes);
144    buf.push(b'\n');
145
146    let file = OpenOptions::new()
147        .create(true)
148        .append(true)
149        .open(path)
150        .ok()?;
151    secure(&file).ok()?;
152    // One `write(2)`, not `write_fmt` and not two calls: a split write is what
153    // a concurrent appender would interleave with.
154    (&file).write_all(&buf).ok()?;
155    file.sync_data().ok()?;
156    drop(file);
157
158    enforce_ring(root, path);
159    Some(())
160}
161
162/// Append a line that is too large for one atomic `write(2)`, serialising
163/// against other writers with the compaction lock instead.
164///
165/// Only the dry-run sink uses this: a whole batch does not fit under
166/// `PIPE_BUF`, and the flush path is neither the panic hook nor the signal
167/// handler, so a **non-blocking** `try_write` is safe there. On contention the
168/// batch is dropped, which is the same fail-open behavior as a failed POST.
169pub fn append_locked(root: &Path, path: &Path, line: &str) -> Option<()> {
170    if tombstone_present(root) {
171        return None;
172    }
173    let bytes = line.as_bytes();
174    if bytes.is_empty() || bytes.len() as u64 + 1 > MAX_BYTES {
175        return None;
176    }
177    ensure_dir(root).ok()?;
178
179    let mut buf = Vec::with_capacity(bytes.len() + 1);
180    buf.extend_from_slice(bytes);
181    buf.push(b'\n');
182
183    let wrote = try_with_lock(root, || {
184        if tombstone_present(root) {
185            return Ok(false);
186        }
187        let file = OpenOptions::new()
188            .create(true)
189            .append(true)
190            .open(path)
191            .with_context(|| format!("failed to open {}", path.display()))?;
192        secure(&file)?;
193        (&file)
194            .write_all(&buf)
195            .with_context(|| format!("failed to append to {}", path.display()))?;
196        file.sync_data()
197            .with_context(|| format!("failed to sync {}", path.display()))?;
198        Ok(true)
199    })
200    .ok()
201    .flatten()
202    .unwrap_or(false);
203
204    if !wrote {
205        return None;
206    }
207    enforce_ring(root, path);
208    Some(())
209}
210
211/// Keep the newest [`MAX_EVENTS`] lines and at most [`MAX_BYTES`], under the
212/// compaction lock. On lock contention this cycle is skipped: the next append
213/// tries again, and the cap is a ceiling on disk footprint, not an invariant
214/// that must hold at every instant.
215fn enforce_ring(root: &Path, path: &Path) {
216    let Ok(meta) = fs::metadata(path) else {
217        return;
218    };
219    let len = meta.len();
220    if len < PROBE_BYTES {
221        return;
222    }
223    let Ok(contents) = fs::read_to_string(path) else {
224        return;
225    };
226    let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect();
227    if lines.len() <= MAX_EVENTS && len <= MAX_BYTES {
228        return;
229    }
230
231    let _ = try_with_lock(root, || {
232        let mut kept: Vec<&str> = lines
233            .iter()
234            .rev()
235            .take(MAX_EVENTS)
236            .rev()
237            .copied()
238            .collect::<Vec<_>>();
239        // Byte ceiling second: drop from the oldest end until the survivors fit.
240        while kept.len() > 1 && byte_len(&kept) > MAX_BYTES {
241            kept.remove(0);
242        }
243        let mut body = kept.join("\n");
244        if !body.is_empty() {
245            body.push('\n');
246        }
247        rewrite(path, body.as_bytes())
248    });
249}
250
251fn byte_len(lines: &[&str]) -> u64 {
252    lines.iter().map(|l| l.len() as u64 + 1).sum()
253}
254
255/// Replace `path` atomically through a sibling temp file in the same directory.
256fn rewrite(path: &Path, bytes: &[u8]) -> Result<()> {
257    let dir = path.parent().unwrap_or_else(|| Path::new("."));
258    let mut tmp = tempfile::NamedTempFile::new_in(dir)
259        .with_context(|| format!("failed to stage a rewrite of {}", path.display()))?;
260    tmp.write_all(bytes)
261        .with_context(|| format!("failed to write a rewrite of {}", path.display()))?;
262    tmp.flush()
263        .with_context(|| format!("failed to flush a rewrite of {}", path.display()))?;
264    secure(tmp.as_file())?;
265    tmp.persist(path)
266        .map_err(|error| error.error)
267        .with_context(|| format!("failed to persist {}", path.display()))?;
268    Ok(())
269}
270
271/// Open (creating if needed) the sibling lock file.
272fn open_lock(root: &Path) -> Result<File> {
273    ensure_dir(root)?;
274    let path = lock_path(root);
275    let file = OpenOptions::new()
276        .create(true)
277        .read(true)
278        .write(true)
279        // The file is only a lock handle; its contents are never read and
280        // truncating it would race other holders for no benefit.
281        .truncate(false)
282        .open(&path)
283        .with_context(|| format!("failed to open {}", path.display()))?;
284    secure(&file)?;
285    Ok(file)
286}
287
288/// Run `operation` holding the exclusive compaction lock, **blocking**.
289///
290/// Only the opt-out wipe uses this. It is not an exit path, so blocking is
291/// fine there and nowhere else.
292pub fn with_lock<T>(root: &Path, operation: impl FnOnce() -> Result<T>) -> Result<T> {
293    let file = open_lock(root)?;
294    let mut lock = fd_lock::RwLock::new(file);
295    let _guard = lock.write().context("failed to take the telemetry lock")?;
296    operation()
297}
298
299/// Run `operation` holding the exclusive compaction lock if it is free.
300///
301/// Returns `Ok(None)` when the lock is held elsewhere. Never blocks.
302pub fn try_with_lock<T>(root: &Path, operation: impl FnOnce() -> Result<T>) -> Result<Option<T>> {
303    let file = open_lock(root)?;
304    let mut lock = fd_lock::RwLock::new(file);
305    match lock.try_write() {
306        Ok(_guard) => operation().map(Some),
307        Err(_) => Ok(None),
308    }
309}
310
311/// Read every intact line from `path`, dropping a torn trailing line.
312///
313/// `std::process::exit` on the signal path can truncate a concurrent write, so
314/// the last line may be a partial JSON document. Skipping unparseable lines is
315/// the whole tolerance: a drain must never fail because one record was cut.
316#[must_use]
317pub fn read_lines(path: &Path) -> Vec<String> {
318    let Ok(contents) = fs::read_to_string(path) else {
319        return Vec::new();
320    };
321    contents
322        .lines()
323        .filter(|line| !line.trim().is_empty())
324        .map(str::to_string)
325        .collect()
326}
327
328/// Take every buffered line and truncate the buffer, under the compaction lock.
329///
330/// Returns an empty vector when the tombstone is present or the lock is held
331/// elsewhere. Truncates rather than unlinks — `crates/tui/src/fleet/ledger.rs`
332/// documents the rule: replacing the file leaves appenders holding the old
333/// inode.
334#[must_use]
335pub fn drain(root: &Path) -> Vec<String> {
336    if tombstone_present(root) {
337        return Vec::new();
338    }
339    let path = buffer_path(root);
340    let drained = try_with_lock(root, || {
341        // Re-check under the lock: a wipe may have landed between the check
342        // above and the acquisition.
343        if tombstone_present(root) {
344            return Ok(Vec::new());
345        }
346        let lines = read_lines(&path);
347        if !lines.is_empty() {
348            truncate(&path)?;
349        }
350        Ok(lines)
351    });
352    drained.ok().flatten().unwrap_or_default()
353}
354
355/// Truncate a file to zero length, leaving the inode in place. A missing file
356/// is not an error.
357pub fn truncate(path: &Path) -> Result<()> {
358    if !path.exists() {
359        return Ok(());
360    }
361    let file = OpenOptions::new()
362        .write(true)
363        .truncate(true)
364        .open(path)
365        .with_context(|| format!("failed to truncate {}", path.display()))?;
366    secure(&file)?;
367    Ok(())
368}
369
370/// Wipe every trace of collection, leaving a permanent tombstone.
371///
372/// Order matters and is the whole of the guarantee:
373///
374/// 1. take the blocking lock — this is not an exit path;
375/// 2. write the tombstone **first**, and never remove it here;
376/// 3. truncate `buffer.jsonl` and `dryrun.jsonl` — do **not** unlink them, and
377///    never unlink the lock file;
378/// 4. remove `install_id.json` and `state.json`.
379///
380/// If any step after the tombstone fails, the error is returned and the caller
381/// logs it — but the tombstone alone already makes the buffer permanently
382/// undrainable, so a failed wipe fails **closed**.
383pub fn wipe(root: &Path) -> Result<()> {
384    with_lock(root, || {
385        let tombstone = tombstone_path(root);
386        let file = OpenOptions::new()
387            .create(true)
388            .write(true)
389            .truncate(true)
390            .open(&tombstone)
391            .with_context(|| format!("failed to write {}", tombstone.display()))?;
392        secure(&file)?;
393        drop(file);
394
395        let mut failure: Option<anyhow::Error> = None;
396        for path in [buffer_path(root), dryrun_path(root)] {
397            if let Err(error) = truncate(&path) {
398                failure.get_or_insert(error);
399            }
400        }
401        for path in [install_id_path(root), state_path(root)] {
402            if path.exists()
403                && let Err(error) = fs::remove_file(&path)
404            {
405                failure.get_or_insert(
406                    anyhow::Error::new(error)
407                        .context(format!("failed to remove {}", path.display())),
408                );
409            }
410        }
411        match failure {
412            Some(error) => Err(error),
413            None => Ok(()),
414        }
415    })
416}
417
418/// Clear the tombstone and drop anything buffered before consent.
419///
420/// Called by `init` on every arming. No event recorded before the user said yes
421/// can be in the batch that follows it — a stale buffer left by an earlier
422/// consenting run, or by a bug, is not evidence of this user's consent.
423pub fn arm(root: &Path) -> Result<()> {
424    ensure_dir(root)?;
425    with_lock(root, || {
426        let tombstone = tombstone_path(root);
427        if tombstone.exists() {
428            fs::remove_file(&tombstone)
429                .with_context(|| format!("failed to remove {}", tombstone.display()))?;
430        }
431        truncate(&buffer_path(root))
432    })
433}