Skip to main content

codewhale_telemetry/
actor.rs

1//! The background writer.
2//!
3//! One dedicated OS thread behind an unbounded `mpsc`. `record()` is a
4//! non-blocking `send` and a hard no-op when the process is not armed;
5//! everything the thread does is wrapped in `catch_unwind`, so a panic inside
6//! telemetry costs telemetry and nothing else.
7//!
8//! A plain thread rather than a `tokio` task, deliberately: `init` is called
9//! from six subcommand dispatch points, several of which have no runtime yet,
10//! and a telemetry subsystem that only works when someone remembered to be
11//! inside an executor is a subsystem that silently collects nothing on half its
12//! surfaces.
13
14use std::panic::{AssertUnwindSafe, catch_unwind};
15use std::path::PathBuf;
16use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, SyncSender, channel, sync_channel};
17use std::time::Duration;
18
19use crate::buffer;
20use crate::client::{self, SendOutcome};
21use crate::decision::{self, TelemetryDecision};
22use crate::envelope;
23use crate::event::{Batch, Event, SCHEMA_VERSION, Surface};
24
25/// Events per batch.
26pub const BATCH_MAX_EVENTS: usize = 200;
27/// Byte ceiling per batch body.
28pub const BATCH_MAX_BYTES: usize = 64 * 1024;
29
30pub(crate) enum Message {
31    Event(Box<Event>),
32    Flush(SyncSender<FlushOutcome>),
33    Shutdown(SyncSender<FlushOutcome>),
34}
35
36/// What a flush attempt did.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum FlushOutcome {
39    /// Nothing was buffered.
40    Empty,
41    /// A batch was written to the dry-run sink.
42    DryRun,
43    /// A batch was accepted by the endpoint.
44    Sent,
45    /// A batch was assembled and dropped — offline, refused, or contended.
46    Dropped,
47    /// Telemetry was off by the time the flush ran; nothing was sent.
48    Suppressed,
49    /// The actor did not answer inside the caller's deadline.
50    TimedOut,
51}
52
53/// Facts the writer thread needs, fixed at arming time.
54#[derive(Debug, Clone)]
55pub(crate) struct Context {
56    pub root: PathBuf,
57    pub endpoint: Option<String>,
58    pub surface: Surface,
59    pub config_path: Option<PathBuf>,
60    pub app_version: String,
61    pub git_sha: Option<String>,
62    pub tty: bool,
63}
64
65/// A handle to the writer thread.
66#[derive(Debug)]
67pub(crate) struct Handle {
68    tx: Sender<Message>,
69}
70
71impl Handle {
72    /// Start the writer thread.
73    pub(crate) fn spawn(context: Context) -> Self {
74        let (tx, rx) = channel::<Message>();
75        // A detached thread: nothing joins it, and the process exiting while it
76        // is mid-write is exactly the case the torn-line tolerance covers.
77        let _ = std::thread::Builder::new()
78            .name("codewhale-telemetry".to_string())
79            .spawn(move || run(&context, &rx));
80        Self { tx }
81    }
82
83    /// Queue an event. Never blocks, never errors upward.
84    pub(crate) fn record(&self, event: Event) {
85        let _ = self.tx.send(Message::Event(Box::new(event)));
86    }
87
88    /// Ask for a flush and wait at most `deadline` for the answer.
89    pub(crate) fn flush(&self, deadline: Duration) -> FlushOutcome {
90        self.round_trip(deadline, Message::Flush)
91    }
92
93    /// Ask for a final flush and stop the thread.
94    pub(crate) fn shutdown(&self, deadline: Duration) -> FlushOutcome {
95        self.round_trip(deadline, Message::Shutdown)
96    }
97
98    fn round_trip(
99        &self,
100        deadline: Duration,
101        build: impl FnOnce(SyncSender<FlushOutcome>) -> Message,
102    ) -> FlushOutcome {
103        let (ack_tx, ack_rx) = sync_channel::<FlushOutcome>(1);
104        if self.tx.send(build(ack_tx)).is_err() {
105            return FlushOutcome::TimedOut;
106        }
107        match ack_rx.recv_timeout(deadline) {
108            Ok(outcome) => outcome,
109            Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => {
110                FlushOutcome::TimedOut
111            }
112        }
113    }
114}
115
116fn run(context: &Context, rx: &Receiver<Message>) {
117    while let Ok(message) = rx.recv() {
118        // Telemetry never takes the process with it. The hook has already been
119        // installed by the time this thread exists, so a panic here is caught,
120        // dropped, and the loop continues.
121        let result = catch_unwind(AssertUnwindSafe(|| match message {
122            Message::Event(event) => {
123                append(context, &event);
124                None
125            }
126            Message::Flush(ack) => {
127                let _ = ack.send(flush(context));
128                None
129            }
130            Message::Shutdown(ack) => {
131                let _ = ack.send(flush(context));
132                Some(())
133            }
134        }));
135        match result {
136            Ok(Some(())) => return,
137            Ok(None) => {}
138            Err(_) => {
139                tracing::debug!("telemetry writer recovered from a panic");
140            }
141        }
142    }
143}
144
145fn append(context: &Context, event: &Event) {
146    let Ok(line) = serde_json::to_string(event) else {
147        return;
148    };
149    let path = buffer::buffer_path(&context.root);
150    let _ = buffer::append(&context.root, &path, &line);
151}
152
153/// Drain, re-check consent, and deliver.
154///
155/// The re-check is the point: telemetry is resolved once at init, but the
156/// documented mid-session opt-out is an external file write this process would
157/// otherwise never observe. If the answer is now `OptedOut`, `decide` has
158/// already wiped and left the tombstone, and the drained events go nowhere.
159fn flush(context: &Context) -> FlushOutcome {
160    match decision::re_decide(context.config_path.as_deref(), context.surface) {
161        TelemetryDecision::Enabled(_) => {}
162        TelemetryDecision::OptedOut | TelemetryDecision::ForcedOff => {
163            return FlushOutcome::Suppressed;
164        }
165    }
166    if buffer::tombstone_present(&context.root) {
167        return FlushOutcome::Suppressed;
168    }
169
170    let lines = buffer::drain(&context.root);
171    if lines.is_empty() {
172        return FlushOutcome::Empty;
173    }
174    let events = parse_events(&lines);
175    if events.is_empty() {
176        return FlushOutcome::Empty;
177    }
178
179    let Ok(install) = envelope::read_or_create_install_id(&context.root) else {
180        return FlushOutcome::Dropped;
181    };
182
183    let mut state = envelope::read_state(&context.root);
184    state.schema_version = SCHEMA_VERSION;
185    state.last_flush = Some(envelope::now_rfc3339());
186    // Written on attempt, not on success, so a permanently offline machine
187    // attempts at most once per interval rather than on every launch.
188    let _ = envelope::write_state(&context.root, &state);
189
190    let batch = Batch {
191        schema_version: SCHEMA_VERSION,
192        sent_at: envelope::now_rfc3339(),
193        install_id: install.install_id,
194        app_version: context.app_version.clone(),
195        git_sha: context.git_sha.clone(),
196        surface: context.surface,
197        os: envelope::current_os(),
198        arch: envelope::current_arch(),
199        libc: envelope::current_libc(),
200        tty: context.tty,
201        events,
202    };
203
204    match client::send(&context.root, context.endpoint.as_deref(), &batch) {
205        SendOutcome::DryRun => FlushOutcome::DryRun,
206        SendOutcome::Accepted => FlushOutcome::Sent,
207        SendOutcome::Dropped => FlushOutcome::Dropped,
208    }
209}
210
211/// Parse drained lines into events, capped at [`BATCH_MAX_EVENTS`] and
212/// [`BATCH_MAX_BYTES`], skipping anything that does not parse **or does not
213/// satisfy its declared string bounds**.
214///
215/// The bound re-check is the point. Everything upstream of here builds events
216/// from closed enums, `u32`s, and two reducers — but this function is a
217/// deserializer, and its input is a file on disk that any process running as
218/// the user can append to. `Event::is_bounded` is what stops
219/// `{"event":"panic","site":"<anything at all>"}` from becoming a first-party
220/// POST under the user's install id.
221pub(crate) fn parse_events(lines: &[String]) -> Vec<Event> {
222    let mut events = Vec::new();
223    let mut bytes = 0usize;
224    for line in lines {
225        if events.len() >= BATCH_MAX_EVENTS || bytes + line.len() > BATCH_MAX_BYTES {
226            break;
227        }
228        if let Ok(event) = serde_json::from_str::<Event>(line) {
229            if !event.is_bounded() {
230                tracing::debug!("telemetry dropped an out-of-bounds buffered event");
231                continue;
232            }
233            bytes += line.len();
234            events.push(event);
235        }
236    }
237    events
238}