lean_ctx/core/
telemetry_send.rs1use crate::core::config::Config;
6
7pub const EXIT_TELEMETRY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
9const BACKGROUND_TELEMETRY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
10
11pub fn telemetry_send_eligible() -> bool {
14 #[cfg(test)]
21 if !crate::core::data_dir::holds_test_env_lock() {
22 return false;
23 }
24 let Ok(config) = Config::try_load_global() else {
25 return false;
26 };
27 let do_not_track = crate::core::host_env::var("DO_NOT_TRACK");
28 let telemetry_override = crate::core::host_env::var("LEAN_CTX_TELEMETRY");
29 config
30 .telemetry
31 .send_eligible(do_not_track.as_deref(), telemetry_override.as_deref())
32}
33
34pub fn send_telemetry(trigger: crate::core::telemetry_aggregate::SendTrigger) -> Option<String> {
38 if !telemetry_send_eligible() {
39 return None;
40 }
41 if let Err(error) = crate::core::telemetry_aggregate::persist_process_counters() {
43 tracing::debug!("telemetry counters not persisted: {error}");
44 }
45 let lease = match crate::core::telemetry_aggregate::begin_send(trigger) {
46 Ok(lease) => lease,
47 Err(reason) => {
48 tracing::debug!("telemetry send skipped: {reason}");
49 return None;
50 }
51 };
52 let batch = lease.batch().clone();
53 let timeout = match trigger {
54 crate::core::telemetry_aggregate::SendTrigger::Exit => EXIT_TELEMETRY_TIMEOUT,
55 crate::core::telemetry_aggregate::SendTrigger::Periodic => BACKGROUND_TELEMETRY_TIMEOUT,
56 };
57 if let Err(error) = crate::telemetry_transport::telemetry_v2_batch_with_timeout(&batch, timeout)
58 {
59 tracing::debug!("telemetry send failed, batch kept for retry: {error}");
60 return None;
61 }
62 let installation_id = batch.events.first()?.installation_id.clone();
63 let payload = serde_json::to_vec(&batch).ok()?;
64 use sha2::Digest;
65 let record = crate::core::telemetry_ledger::HeartbeatRecord {
66 timestamp: chrono::Utc::now().to_rfc3339(),
67 installation_id,
68 version: env!("CARGO_PKG_VERSION").to_string(),
69 os: std::env::consts::OS.to_string(),
70 arch: std::env::consts::ARCH.to_string(),
71 schema_version: batch.schema_version,
72 event_names: batch
73 .events
74 .iter()
75 .map(|event| event.event.name().to_string())
76 .collect(),
77 payload_hash: hex::encode(sha2::Sha256::digest(payload)),
78 endpoint: telemetry_ledger_endpoint(),
79 status: "success".to_string(),
80 };
81 if let Err(error) = crate::core::telemetry_ledger::append(&record) {
84 tracing::debug!("telemetry ledger append failed: {error}");
85 return None;
86 }
87 if let Err(error) = lease.commit() {
88 tracing::debug!("telemetry acknowledgement failed: {error}");
89 return None;
90 }
91 batch
92 .events
93 .first()
94 .map(|event| event.timestamp_bucket.clone())
95}
96
97#[cfg(feature = "http-server")]
100const DAEMON_TELEMETRY_INTERVAL: std::time::Duration = std::time::Duration::from_hours(1);
101#[cfg(feature = "http-server")]
103const DAEMON_TELEMETRY_INITIAL_DELAY: std::time::Duration = std::time::Duration::from_mins(2);
104
105#[cfg(feature = "http-server")]
113pub fn spawn_daemon_telemetry() {
114 tokio::spawn(async {
115 tokio::time::sleep(DAEMON_TELEMETRY_INITIAL_DELAY).await;
116 loop {
117 let _ = tokio::task::spawn_blocking(daemon_telemetry_tick).await;
118 tokio::time::sleep(DAEMON_TELEMETRY_INTERVAL).await;
119 }
120 });
121}
122
123#[cfg(feature = "http-server")]
124fn daemon_telemetry_tick() {
125 if !telemetry_send_eligible() {
126 return;
127 }
128 if let Err(error) = crate::core::telemetry_aggregate::record_current_version() {
129 tracing::debug!("telemetry version aggregate unavailable: {error}");
130 }
131 send_telemetry(crate::core::telemetry_aggregate::SendTrigger::Periodic);
132}
133
134fn telemetry_ledger_endpoint() -> String {
137 sanitized_telemetry_endpoint(&crate::telemetry_transport::api_url())
138}
139
140fn sanitized_telemetry_endpoint(raw: &str) -> String {
141 let Ok(mut url) = reqwest::Url::parse(raw) else {
142 return "https://api.leanctx.com/api/telemetry/v2/batch".to_string();
143 };
144 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
145 return "https://api.leanctx.com/api/telemetry/v2/batch".to_string();
146 }
147 let _ = url.set_username("");
148 let _ = url.set_password(None);
149 url.set_query(None);
150 url.set_fragment(None);
151 url.set_path("/api/telemetry/v2/batch");
152 url.to_string()
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn telemetry_ledger_endpoint_removes_credentials_query_and_fragment() {
161 assert_eq!(
162 sanitized_telemetry_endpoint("https://user:secret@example.test/base?token=x#private"),
163 "https://example.test/api/telemetry/v2/batch"
164 );
165 assert_eq!(
166 sanitized_telemetry_endpoint("file:///private/path"),
167 "https://api.leanctx.com/api/telemetry/v2/batch"
168 );
169 }
170
171 #[test]
172 fn telemetry_ledger_records_the_endpoint_the_send_used() {
173 let _lock = crate::core::data_dir::test_env_lock();
174 crate::test_env::set_var("LEAN_CTX_API_URL", "https://api.leanctx.com");
175 let endpoint = telemetry_ledger_endpoint();
176 crate::test_env::remove_var("LEAN_CTX_API_URL");
177 assert_eq!(endpoint, "http://127.0.0.1:9/api/telemetry/v2/batch");
179 }
180
181 #[cfg(feature = "http-server")]
182 #[test]
183 #[serial_test::serial]
184 fn daemon_tick_honours_an_explicit_opt_out() {
185 let _iso = crate::core::data_dir::isolated_data_dir();
186 let config = Config::path().expect("config path");
187 std::fs::create_dir_all(config.parent().expect("config dir")).expect("config dir");
188 std::fs::write(&config, "[telemetry]\nenabled = false\n").expect("write opt-out");
189 daemon_telemetry_tick();
190 let state = crate::core::paths::state_dir().expect("state dir");
191 assert!(!state.join("telemetry_v2_aggregate.json").exists());
192 assert!(!state.join("telemetry_v2_one_shots.json").exists());
193 }
194
195 #[cfg(feature = "http-server")]
196 #[test]
197 #[serial_test::serial]
198 fn daemon_tick_attempts_a_send_for_a_default_install() {
199 let _iso = crate::core::data_dir::isolated_data_dir();
203 daemon_telemetry_tick();
204 let state = crate::core::paths::state_dir().expect("state dir");
205 assert!(state.join("telemetry_v2_aggregate.json").exists());
206 }
207
208 #[test]
214 #[serial_test::serial]
215 fn a_thread_without_the_test_lock_never_sends() {
216 let _iso = crate::core::data_dir::isolated_data_dir();
217 let sent = std::thread::spawn(|| {
218 send_telemetry(crate::core::telemetry_aggregate::SendTrigger::Periodic)
219 })
220 .join()
221 .expect("send thread");
222 assert_eq!(sent, None);
223 let state = crate::core::paths::state_dir().expect("state dir");
224 assert!(!state.join("telemetry_v2_aggregate.json").exists());
225 assert!(!state.join("telemetry_v2_one_shots.json").exists());
226 }
227}