Skip to main content

lean_ctx/core/
telemetry_send.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Sending the telemetry-v2 daily totals: on MCP exit, on tool-call ticks and
3//! from the daemon's hourly loop, with the local send ledger.
4
5use crate::core::config::Config;
6
7/// Network budget for the send that runs as the MCP server exits.
8pub 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
11/// Whether this process may collect and send telemetry right now. Fails closed:
12/// an unreadable config never counts as consent.
13pub fn telemetry_send_eligible() -> bool {
14    // In a test process only a thread that holds the test environment lock
15    // knows whose data directory it resolves. The detached send of a tool
16    // call's background tick runs on a thread of its own and resolved the
17    // isolated directory of whichever test held the lock: it took that
18    // test's aggregate lease, and that test's preview failed with "a send is
19    // in progress" (25.10.).
20    #[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
34/// Send the cumulative telemetry totals if the aggregate admits a send now.
35/// Returns the acknowledged bucket. Admission (daily cap, spacing, "nothing
36/// new") is decided under the aggregate lock, so callers may call freely.
37pub fn send_telemetry(trigger: crate::core::telemetry_aggregate::SendTrigger) -> Option<String> {
38    if !telemetry_send_eligible() {
39        return None;
40    }
41    // Persist first so the counters survive even when no send is admitted.
42    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    // Without a ledger entry the send stays pending and is retried with the
82    // same bytes; the server replaces per-day rows, so that is harmless.
83    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/// How often the daemon offers the day's totals. The aggregate's admission
98/// (daily cap, growing spacing, "nothing new") decides whether a send happens.
99#[cfg(feature = "http-server")]
100const DAEMON_TELEMETRY_INTERVAL: std::time::Duration = std::time::Duration::from_hours(1);
101/// Delay before the daemon's first offer so its startup stays light.
102#[cfg(feature = "http-server")]
103const DAEMON_TELEMETRY_INITIAL_DELAY: std::time::Duration = std::time::Duration::from_mins(2);
104
105/// Spawn the daemon's telemetry loop (must run inside a Tokio runtime).
106///
107/// Without it, sends happen only inside an MCP server (tool-call ticks and
108/// exit), so installs that use lean-ctx through hooks, the CLI or the proxy
109/// were never counted. The daemon is the one long-lived process setup starts;
110/// it also forwards the counters other processes persisted. Eligibility is
111/// re-checked on every tick, so an opt-out takes effect without a restart.
112#[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
134/// The endpoint the send actually used: `api_url()`, so a test or
135/// cargo-launched process records the loopback sink, not production.
136fn 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        // A test process sends to the loopback sink, so it records that.
178        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        // No config file: the default-on install the daemon exists to cover.
200        // The test guard sends to a discard port, so the batch stays pending,
201        // which proves the tick reached the send path.
202        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    /// A send from a thread without the test environment lock — the detached
209    /// background tick of a tool call — resolves whichever data directory a
210    /// locked test isolated at that moment, and its lease made that test's
211    /// preview fail (25.10.). In a test process it sends nothing; the locked
212    /// thread itself still reaches the send path (test above).
213    #[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}