返回 CodeWhale
client.rs
根目录 / crates / telemetry / src / 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 after every
7 //! persistent and run-scoped opt-out was applied.
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 /// Network delivery holds the same non-blocking privacy lock as appends and
39 /// wipe. A wipe waits for an already-started POST to finish; a POST that races
40 /// a held or completed wipe is dropped before reaching the wire. Therefore no
41 /// delivery can remain in flight after persistent opt-out returns.
42 pub fn send(root: &Path, endpoint: Option<&str>, batch: &Batch) -> SendOutcome {
43 send_with_transport(root, endpoint, batch, post)
44 }
45
46 pub(crate) fn send_with_transport(
47 root: &Path,
48 endpoint: Option<&str>,
49 batch: &Batch,
50 transport: impl FnOnce(&str, &str, String) -> SendOutcome,
51 ) -> SendOutcome {
52 let Ok(body) = serde_json::to_string(batch) else {
53 return SendOutcome::Dropped;
54 };
55 match endpoint {
56 None => {
57 let path = buffer::dryrun_path(root);
58 match buffer::append_locked(root, &path, &body) {
59 Some(()) => SendOutcome::DryRun,
60 None => SendOutcome::Dropped,
61 }
62 }
63 Some(endpoint) => buffer::try_with_lock(root, || {
64 if buffer::tombstone_present(root) {
65 return Ok(SendOutcome::Dropped);
66 }
67 Ok(transport(endpoint, &batch.app_version, body))
68 })
69 .ok()
70 .flatten()
71 .unwrap_or(SendOutcome::Dropped),
72 }
73 }
74
75 /// A single first-party POST.
76 ///
77 /// The client is built through `codewhale_release::platform_blocking_http_client_builder`,
78 /// never by hand: `reqwest` is pinned workspace-wide with `rustls-no-provider`,
79 /// so a construction that skips the provider install silently never connects on
80 /// some platforms — indistinguishable from fail-open, which means no test that
81 /// merely asserts "does not crash" would catch it. Android additionally needs
82 /// the webpki-roots swap, which that builder owns.
83 ///
84 /// No cookies, no redirects, no auth header, no custom headers. The response
85 /// body is discarded and only the status class is read: this client must never
86 /// be made to depend on a server response.
87 fn post(endpoint: &str, app_version: &str, body: String) -> SendOutcome {
88 let client = codewhale_release::platform_blocking_http_client_builder()
89 .timeout(SEND_TIMEOUT)
90 // No cookie store exists to disable: `reqwest` is pinned workspace-wide
91 // without the `cookies` feature, so there is no jar to carry state
92 // between batches even if a server tried to set one.
93 .redirect(reqwest::redirect::Policy::none())
94 .user_agent(format!("codewhale-telemetry/{app_version}"))
95 .build();
96 let Ok(client) = client else {
97 return SendOutcome::Dropped;
98 };
99 match client
100 .post(endpoint)
101 .header(reqwest::header::CONTENT_TYPE, "application/json")
102 .body(body)
103 .send()
104 {
105 Ok(response) if response.status().is_success() => SendOutcome::Accepted,
106 _ => SendOutcome::Dropped,
107 }
108 }
109
109 lines RUST