Skip to main content

codewhale_telemetry/
client.rs

1//! Transport. One POST, or — with no endpoint — a local file.
2//!
3//! The shipped default endpoint is `codewhale_config::DEFAULT_TELEMETRY_ENDPOINT`,
4//! the first-party ingest service documented in `docs/TELEMETRY.md`. That
5//! default decides only *where* a batch goes, never *whether* one exists: this
6//! module is reached only by a session that resolved telemetry on, which
7//! requires the first-run notice to have been answered with Enable.
8//!
9//! `None` here is the dry-run sink, reachable by configuring an empty endpoint:
10//! batches are serialized with the same serializer a real endpoint would see and
11//! appended to `dryrun.jsonl`, and no HTTP client is ever constructed. That is
12//! how you read your own payloads — by reading the file.
13
14use std::path::Path;
15use std::time::Duration;
16
17use crate::buffer;
18use crate::event::Batch;
19
20/// Transport timeout, matching the release-metadata timeout.
21pub const SEND_TIMEOUT: Duration = Duration::from_secs(5);
22
23/// What happened to a batch.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum SendOutcome {
26    /// Written to `dryrun.jsonl`.
27    DryRun,
28    /// Accepted by the endpoint.
29    Accepted,
30    /// Dropped. No retry, no backoff, no re-queue — a permanently offline
31    /// machine attempts at most once per flush interval and never grows a
32    /// queue.
33    Dropped,
34}
35
36/// Serialize and deliver one batch.
37///
38/// The tombstone is re-checked immediately before delivery, so a wipe that
39/// landed while the batch was being assembled still stops it.
40pub fn send(root: &Path, endpoint: Option<&str>, batch: &Batch) -> SendOutcome {
41    if buffer::tombstone_present(root) {
42        return SendOutcome::Dropped;
43    }
44    let Ok(body) = serde_json::to_string(batch) else {
45        return SendOutcome::Dropped;
46    };
47    match endpoint {
48        None => {
49            let path = buffer::dryrun_path(root);
50            match buffer::append_locked(root, &path, &body) {
51                Some(()) => SendOutcome::DryRun,
52                None => SendOutcome::Dropped,
53            }
54        }
55        Some(endpoint) => post(endpoint, &batch.app_version, body),
56    }
57}
58
59/// A single first-party POST.
60///
61/// The client is built through `codewhale_release::platform_blocking_http_client_builder`,
62/// never by hand: `reqwest` is pinned workspace-wide with `rustls-no-provider`,
63/// so a construction that skips the provider install silently never connects on
64/// some platforms — indistinguishable from fail-open, which means no test that
65/// merely asserts "does not crash" would catch it. Android additionally needs
66/// the webpki-roots swap, which that builder owns.
67///
68/// No cookies, no redirects, no auth header, no custom headers. The response
69/// body is discarded and only the status class is read: this client must never
70/// be made to depend on a server response.
71fn post(endpoint: &str, app_version: &str, body: String) -> SendOutcome {
72    let client = codewhale_release::platform_blocking_http_client_builder()
73        .timeout(SEND_TIMEOUT)
74        // No cookie store exists to disable: `reqwest` is pinned workspace-wide
75        // without the `cookies` feature, so there is no jar to carry state
76        // between batches even if a server tried to set one.
77        .redirect(reqwest::redirect::Policy::none())
78        .user_agent(format!("codewhale-telemetry/{app_version}"))
79        .build();
80    let Ok(client) = client else {
81        return SendOutcome::Dropped;
82    };
83    match client
84        .post(endpoint)
85        .header(reqwest::header::CONTENT_TYPE, "application/json")
86        .body(body)
87        .send()
88    {
89        Ok(response) if response.status().is_success() => SendOutcome::Accepted,
90        _ => SendOutcome::Dropped,
91    }
92}