| 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 | |
| 14 | use std::path::Path; |
| 15 | use std::time::Duration; |
| 16 | |
| 17 | use crate::buffer; |
| 18 | use crate::event::Batch; |
| 19 | |
| 20 | /// Transport timeout, matching the release-metadata timeout. |
| 21 | pub const SEND_TIMEOUT: Duration = Duration::from_secs(5); |
| 22 | |
| 23 | /// What happened to a batch. |
| 24 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 25 | pub 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. |
| 40 | pub 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. |
| 71 | fn 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 | } |
| 93 |