返回 CodeWhale
actor.rs
根目录 / crates / telemetry / src / 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
14 use std::panic::{AssertUnwindSafe, catch_unwind};
15 use std::path::PathBuf;
16 use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, SyncSender, channel, sync_channel};
17 use std::time::Duration;
18
19 use crate::buffer;
20 use crate::client::{self, SendOutcome};
21 use crate::decision::{self, TelemetryDecision};
22 use crate::envelope;
23 use crate::event::{Batch, Event, SCHEMA_VERSION, Surface};
24
25 /// Events per batch.
26 pub const BATCH_MAX_EVENTS: usize = 200;
27 /// Byte ceiling per batch body.
28 pub const BATCH_MAX_BYTES: usize = 64 * 1024;
29
30 pub(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)]
38 pub 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)]
55 pub(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)]
67 pub(crate) struct Handle {
68 tx: Sender<Message>,
69 }
70
71 impl 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
116 fn 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
145 fn 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.
159 fn 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.
221 pub(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 }
239
239 lines RUST