Skip to main content

lean_ctx/core/
telemetry_aggregate.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Privacy-safe daily telemetry aggregation.
4//!
5//! Every UTC day keeps cumulative totals. A send carries the full totals of
6//! today (and of any closed day with unsent activity) under that day's bucket.
7//! The server replaces per-day rows, so sending several times a day is
8//! idempotent: tool usage reaches the server within the day, and a one-day
9//! user is counted without having to come back.
10
11use sha2::Digest;
12use std::collections::{BTreeMap, BTreeSet, HashMap};
13use std::path::PathBuf;
14use std::sync::{Mutex, OnceLock};
15
16use fs2::FileExt;
17use serde::{Deserialize, Serialize};
18
19use super::installation_id;
20use super::telemetry_v2::{
21    Architecture, ClientFamily, DecisionMetrics, DistributionChannel, ErrorCategory, ErrorMetrics,
22    HeartbeatMetrics, Histogram, MAX_BATCH_EVENTS, MAX_COUNT, MAX_TOOL_ENTRIES, OccurrenceMetrics,
23    OperatingSystem, SCHEMA_VERSION, SessionMetrics, SyncMetrics, TelemetryBatchV2,
24    TelemetryEnvelopeV2, TelemetryEventV2, TokenMetrics, ToolCallCount, ToolCallMetrics,
25    ToolUsageMetrics, VersionUpgradeMetrics, valid_feature_code, valid_tool_name,
26};
27
28mod counters;
29mod environment;
30mod features;
31mod history;
32use super::telemetry_failure::{KindCounts, sub_kinds, wire_kinds, wire_messages};
33use counters::{add_counters, counter_delta};
34use environment::{client_family, distribution_channel, setup_profile};
35use features::{FeatureTally, add_feature, feature_metrics};
36
37/// Most send attempts per installation and UTC day: a snapshot every half hour
38/// of a day in use. The server admits sixty (from Oct 2026; ten before); the
39/// rest stays in reserve for retries and clock skew between client and server.
40pub const DAILY_SEND_CAP: u32 = 48;
41/// Backoff base while today's first send has not been acknowledged yet.
42const RETRY_BACKOFF_SECS: i64 = 60;
43/// Flat spacing after the first acknowledged send of a day: while LeanCTX is in
44/// use the latest totals are never more than half an hour old. (It used to
45/// double up to two hours, leaving evening activity unreported until morning.)
46const RESEND_INTERVAL_SECS: i64 = 30 * 60;
47/// Ceiling for the retry backoff of an unacknowledged send.
48const MAX_SEND_INTERVAL_SECS: i64 = 2 * 60 * 60;
49/// Flat spacing for the exit flush, so a session's final calls still arrive.
50const EXIT_RESEND_INTERVAL_SECS: i64 = 5 * 60;
51/// Closed days with unsent activity kept locally besides today.
52const RETAINED_CLOSED_DAYS: usize = 7;
53
54/// What prompted a send attempt.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SendTrigger {
57    /// Background cadence while tools are being called.
58    Periodic,
59    /// Final flush as the MCP server exits; may use the last daily slot.
60    Exit,
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(deny_unknown_fields)]
65struct CounterCheckpoint {
66    tool_calls: u64,
67    tool_failures: u64,
68    tool_latency_buckets: [u64; crate::core::telemetry::TOOL_LATENCY_BUCKET_UPPER_MS.len()],
69    session_uptime_secs: u64,
70    /// Per-tool counters. Absent in state written before per-tool counting.
71    #[serde(default)]
72    tools: BTreeMap<String, ToolCounterCheckpoint>,
73    /// Tool-output tokens. Absent in state written before 3.11.1.
74    #[serde(default)]
75    tokens_input: u64,
76    #[serde(default)]
77    tokens_output: u64,
78    /// Scrubbed failure templates, keyed `tool\ttemplate` (3.11.1).
79    #[serde(default)]
80    failure_messages: BTreeMap<String, u64>,
81}
82
83#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85struct ToolCounterCheckpoint {
86    calls: u64,
87    failures: u64,
88    #[serde(default)]
89    latency_us: u64,
90    #[serde(default)]
91    failure_kinds: KindCounts,
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96struct AggregateState {
97    #[serde(default)]
98    installation_id: String,
99    /// Legacy in-process baseline, kept so older state files still load.
100    /// Unsent counters now live durably in [`QueuedOneShots::counters`].
101    process_nonce: String,
102    acknowledged: CounterCheckpoint,
103    pending: Option<PendingBatch>,
104    /// Bucket of the most recently acknowledged batch.
105    #[serde(default)]
106    last_sent_bucket: Option<String>,
107    /// Unix time of the most recent admitted send attempt.
108    #[serde(default)]
109    last_attempt_unix: Option<i64>,
110    /// UTC day that `attempts_in_bucket` counts for.
111    #[serde(default)]
112    attempt_bucket: Option<String>,
113    #[serde(default)]
114    attempts_in_bucket: u32,
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118#[serde(deny_unknown_fields)]
119struct OneShotState {
120    #[serde(default)]
121    installation_id: String,
122    setup_recorded: bool,
123    configured_integrations: BTreeSet<String>,
124    observed_major: Option<u16>,
125    #[serde(default)]
126    last_acknowledged_batch: Option<String>,
127    /// Pre-daily-totals queue. Loaded for compatibility and migrated into
128    /// today's totals; never written with content again.
129    #[serde(default, skip_serializing_if = "QueuedOneShots::is_empty")]
130    queued: QueuedOneShots,
131    /// Cumulative totals per UTC day, keyed `YYYY-MM-DD`.
132    #[serde(default)]
133    days: BTreeMap<String, DayTotals>,
134}
135
136#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138struct DayTotals {
139    totals: QueuedOneShots,
140    /// The totals the server last acknowledged for this day.
141    #[serde(default)]
142    sent: QueuedOneShots,
143}
144
145impl DayTotals {
146    fn unsent(&self) -> bool {
147        self.totals != self.sent
148    }
149}
150
151#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(deny_unknown_fields)]
153struct QueuedOneShots {
154    setup_completed: bool,
155    integrations_detected: u64,
156    version_upgrade: Option<VersionTransition>,
157    #[serde(default)]
158    sync: SyncMetrics,
159    #[serde(default)]
160    autopilot: DecisionMetrics,
161    #[serde(default)]
162    autopilot_fallback: DecisionMetrics,
163    #[serde(default)]
164    checkout_started: u64,
165    #[serde(default)]
166    error_categories: [u64; 8],
167    /// Shell commands that exited non-zero (3.11.1); kept apart from the
168    /// fixed-size category array so older state still loads.
169    #[serde(default)]
170    command_errors: u64,
171    /// Tool counters folded in by every process, so short sessions reach the
172    /// server even when the process exits before the next send.
173    #[serde(default)]
174    counters: CounterCheckpoint,
175    /// CLI commands and background features by registry code (3.11.1).
176    #[serde(default)]
177    features: BTreeMap<String, FeatureTally>,
178}
179
180impl QueuedOneShots {
181    fn is_empty(&self) -> bool {
182        *self == Self::default()
183    }
184
185    /// Merge `extra` into these totals, saturating at the contract bounds.
186    fn absorb(&mut self, extra: &Self) {
187        self.setup_completed |= extra.setup_completed;
188        self.integrations_detected = self
189            .integrations_detected
190            .saturating_add(extra.integrations_detected)
191            .min(MAX_COUNT);
192        self.version_upgrade = match (self.version_upgrade, extra.version_upgrade) {
193            (Some(own), Some(other)) => Some(VersionTransition {
194                from_major: own.from_major.min(other.from_major),
195                to_major: own.to_major.max(other.to_major),
196            }),
197            (own, other) => own.or(other),
198        };
199        self.sync.attempts = self
200            .sync
201            .attempts
202            .saturating_add(extra.sync.attempts)
203            .min(MAX_COUNT);
204        self.sync.successes = self
205            .sync
206            .successes
207            .saturating_add(extra.sync.successes)
208            .min(self.sync.attempts);
209        self.sync.failures = self
210            .sync
211            .failures
212            .saturating_add(extra.sync.failures)
213            .min(self.sync.attempts - self.sync.successes);
214        add_decisions(&mut self.autopilot, &extra.autopilot);
215        add_decisions(&mut self.autopilot_fallback, &extra.autopilot_fallback);
216        self.checkout_started = self
217            .checkout_started
218            .saturating_add(extra.checkout_started)
219            .min(MAX_COUNT);
220        for (own, other) in self.error_categories.iter_mut().zip(extra.error_categories) {
221            *own = own.saturating_add(other).min(MAX_COUNT);
222        }
223        self.command_errors = self
224            .command_errors
225            .saturating_add(extra.command_errors)
226            .min(MAX_COUNT);
227        add_counters(&mut self.counters, &extra.counters);
228        for (code, tally) in &extra.features {
229            add_feature(&mut self.features, code, *tally);
230        }
231    }
232}
233
234fn add_decisions(total: &mut DecisionMetrics, extra: &DecisionMetrics) {
235    total.admitted = total.admitted.saturating_add(extra.admitted).min(MAX_COUNT);
236    total.denied = total.denied.saturating_add(extra.denied).min(MAX_COUNT);
237    total.fallback = total.fallback.saturating_add(extra.fallback).min(MAX_COUNT);
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(deny_unknown_fields)]
242struct VersionTransition {
243    from_major: u16,
244    to_major: u16,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248#[serde(deny_unknown_fields)]
249struct PendingBatch {
250    batch: TelemetryBatchV2,
251    #[serde(default)]
252    acknowledgement_id: String,
253    observed: CounterCheckpoint,
254    process_nonce: String,
255    /// Legacy subtract-on-acknowledge snapshot; new batches use `included_days`.
256    #[serde(default, skip_serializing_if = "QueuedOneShots::is_empty")]
257    included_one_shots: QueuedOneShots,
258    /// Exact per-day totals this batch carries, recorded as sent on success.
259    #[serde(default)]
260    included_days: BTreeMap<String, QueuedOneShots>,
261}
262
263pub struct DailySendLease {
264    batch: TelemetryBatchV2,
265    state_path: PathBuf,
266    one_shot_path: PathBuf,
267    _lock: std::fs::File,
268}
269
270impl DailySendLease {
271    pub fn batch(&self) -> &TelemetryBatchV2 {
272        &self.batch
273    }
274
275    pub fn commit(self) -> Result<(), String> {
276        acknowledge_at(&self.state_path, &self.one_shot_path, &self.batch)
277    }
278}
279
280pub fn pending_daily_batch() -> Result<TelemetryBatchV2, String> {
281    preview_daily_batch()
282}
283
284/// Build the exact currently eligible payload without advancing durable state.
285pub fn preview_daily_batch() -> Result<TelemetryBatchV2, String> {
286    let path = state_path()?;
287    ensure_parent(&path)?;
288    let lock = open_state_lock(&path)?;
289    lock.try_lock_exclusive().map_err(|error| {
290        if super::file_lock::is_contended(&error) {
291            "exact telemetry preview unavailable while a send is in progress".to_string()
292        } else {
293            format!("cannot lock telemetry aggregate state for preview: {error}")
294        }
295    })?;
296    let state = state_for_current_identity(load_state_at(&path)?)?;
297    if let Some(pending) = state.pending {
298        return Ok(pending.batch);
299    }
300    let sidecar_path = one_shot_path()?;
301    ensure_parent(&sidecar_path)?;
302    let sidecar_lock = open_sidecar_lock(&sidecar_path)?;
303    // Tool calls hold this lock briefly to persist counters; wait out that
304    // window instead of failing a concurrent preview.
305    lock_telemetry_file(&sidecar_lock, "one-shot").map_err(|error| {
306        format!("exact telemetry preview unavailable: cannot lock one-shot state: {error}")
307    })?;
308    let mut one_shots = one_shots_for_current_identity(load_one_shots_at(&sidecar_path)?)?;
309    // Fold in memory only: the preview must not advance durable state.
310    let today = current_send_bucket();
311    fold_process_counters(&sidecar_path, &mut one_shots, &today);
312    build_batch(&one_shots, &today).map(|(batch, _)| batch)
313}
314
315/// Freeze one payload until the sender explicitly acknowledges success,
316/// bypassing admission so tests can drive the two phases directly.
317#[cfg(test)]
318fn prepare_daily_batch() -> Result<TelemetryBatchV2, String> {
319    with_locked_state(|mut state| {
320        if let Some(pending) = &state.pending {
321            return Ok((state.clone(), pending.batch.clone()));
322        }
323        let (batch, _) = freeze_batch(&mut state, &current_send_bucket())?;
324        Ok((state, batch))
325    })
326}
327
328/// Hold the cross-process state lease until network, ledger, and
329/// acknowledgement finish. Refuses while the next send is not yet due.
330pub fn begin_daily_send() -> Result<DailySendLease, String> {
331    begin_send(SendTrigger::Periodic)
332}
333
334/// Admission and payload resolve one clock reading under the aggregate lock,
335/// so competing senders cannot both pass a stale caller-side precheck.
336pub fn begin_send(trigger: SendTrigger) -> Result<DailySendLease, String> {
337    let path = state_path()?;
338    let one_shot_path = one_shot_path()?;
339    ensure_parent(&path)?;
340    let lock = open_state_lock(&path)?;
341    lock_telemetry_file(&lock, "aggregate")?;
342    let mut state = state_for_current_identity(load_state()?)?;
343    let (bucket, now) = send_clock();
344    if state.attempt_bucket.as_deref() != Some(bucket.as_str()) {
345        state.attempt_bucket = Some(bucket.clone());
346        state.attempts_in_bucket = 0;
347    }
348    let sent_today = state.last_sent_bucket.as_deref() == Some(bucket.as_str());
349    admit(&state, now, trigger, sent_today)?;
350    let batch = if let Some(pending) = &state.pending {
351        // Retries remain at-least-once with the exact frozen bytes, including
352        // across UTC day boundaries.
353        pending.batch.clone()
354    } else {
355        let (batch, unsent) = freeze_batch(&mut state, &bucket)?;
356        if sent_today && !unsent {
357            // Nothing new since the last acknowledged send. The fold above
358            // already persisted this process's counters, which is harmless.
359            return Err(format!("telemetry for {bucket} is already up to date"));
360        }
361        batch
362    };
363    state.last_attempt_unix = Some(now);
364    state.attempts_in_bucket = state.attempts_in_bucket.saturating_add(1);
365    write_state(&path, &state)?;
366    Ok(DailySendLease {
367        batch,
368        state_path: path,
369        one_shot_path,
370        _lock: lock,
371    })
372}
373
374/// Whether the next attempt is due. Attempts in a day are capped below the
375/// server's own limit. An acknowledged day is re-sent at a flat half-hour
376/// spacing while tools are in use; an unacknowledged send backs off, so an
377/// offline machine retries ever more rarely.
378fn admit(
379    state: &AggregateState,
380    now: i64,
381    trigger: SendTrigger,
382    sent_today: bool,
383) -> Result<(), String> {
384    let cap = match trigger {
385        SendTrigger::Periodic => DAILY_SEND_CAP - 1,
386        SendTrigger::Exit => DAILY_SEND_CAP,
387    };
388    let attempts = state.attempts_in_bucket;
389    if attempts >= cap {
390        return Err(format!(
391            "telemetry daily send limit reached ({attempts} of {cap} attempts)"
392        ));
393    }
394    let interval = if attempts == 0 {
395        0
396    } else if state.pending.is_some() || !sent_today {
397        backoff(RETRY_BACKOFF_SECS, attempts)
398    } else if trigger == SendTrigger::Exit {
399        EXIT_RESEND_INTERVAL_SECS
400    } else {
401        RESEND_INTERVAL_SECS
402    };
403    // A clock that moved backwards must not stall sending until it catches up.
404    let elapsed = state
405        .last_attempt_unix
406        .map_or(i64::MAX, |last| now.saturating_sub(last));
407    if elapsed >= 0 && elapsed < interval {
408        return Err(format!(
409            "telemetry send not due for another {}s",
410            interval - elapsed
411        ));
412    }
413    Ok(())
414}
415
416fn backoff(base: i64, attempts: u32) -> i64 {
417    base.saturating_mul(1_i64 << attempts.saturating_sub(1).min(16))
418        .min(MAX_SEND_INTERVAL_SECS)
419}
420
421/// Fold this process's counters, then freeze the batch for `bucket` as the
422/// pending payload. Also reports whether the batch carries any totals the
423/// server has not acknowledged yet. Holds the one-shot lock only for the fold.
424fn freeze_batch(
425    state: &mut AggregateState,
426    bucket: &str,
427) -> Result<(TelemetryBatchV2, bool), String> {
428    let one_shot_path = one_shot_path()?;
429    ensure_parent(&one_shot_path)?;
430    let one_shot_lock = open_sidecar_lock(&one_shot_path)?;
431    lock_telemetry_file(&one_shot_lock, "one-shot")?;
432    let mut one_shots = one_shots_for_current_identity(load_one_shots_at(&one_shot_path)?)?;
433    let observed = fold_process_counters(&one_shot_path, &mut one_shots, bucket);
434    write_one_shots(&one_shot_path, &one_shots)?;
435    mark_folded(&one_shot_path, observed.clone());
436    let (batch, included_days) = build_batch(&one_shots, bucket)?;
437    // A day without an entry has no activity, so it has nothing unsent.
438    let unsent = included_days
439        .keys()
440        .any(|day| one_shots.days.get(day).is_some_and(DayTotals::unsent));
441    state.installation_id = batch_installation_id(&batch).to_string();
442    state.pending = Some(PendingBatch {
443        batch: batch.clone(),
444        acknowledgement_id: uuid::Uuid::new_v4().to_string(),
445        observed,
446        process_nonce: process_nonce().to_string(),
447        included_one_shots: QueuedOneShots::default(),
448        included_days,
449    });
450    Ok((batch, unsent))
451}
452
453/// Advance durable state only when the exact frozen payload was accepted.
454#[cfg(test)]
455fn acknowledge_daily_batch(batch: &TelemetryBatchV2) -> Result<(), String> {
456    let path = state_path()?;
457    ensure_parent(&path)?;
458    let lock = open_state_lock(&path)?;
459    lock.lock_exclusive()
460        .map_err(|error| format!("cannot lock telemetry aggregate state: {error}"))?;
461    acknowledge_at(&path, &one_shot_path()?, batch)
462}
463
464fn acknowledge_at(
465    state_path: &std::path::Path,
466    one_shot_path: &std::path::Path,
467    batch: &TelemetryBatchV2,
468) -> Result<(), String> {
469    let current_state = load_state_at(state_path)?;
470    let pending = current_state
471        .pending
472        .as_ref()
473        .ok_or_else(|| "no prepared telemetry batch to acknowledge".to_string())?;
474    let included = pending.included_days.clone();
475    let acknowledgement_id = pending_acknowledgement_id(pending)?;
476    let state = acknowledge_state(current_state, batch)?;
477    acknowledge_one_shots_at(one_shot_path, &included, &acknowledgement_id)?;
478    write_state(state_path, &state)
479}
480
481fn pending_acknowledgement_id(pending: &PendingBatch) -> Result<String, String> {
482    if !pending.acknowledgement_id.is_empty() {
483        return Ok(pending.acknowledgement_id.clone());
484    }
485    let encoded = serde_json::to_vec(&pending.batch)
486        .map_err(|error| format!("cannot encode telemetry acknowledgement: {error}"))?;
487    Ok(hex::encode(sha2::Sha256::digest(encoded)))
488}
489
490fn acknowledge_state(
491    mut state: AggregateState,
492    batch: &TelemetryBatchV2,
493) -> Result<AggregateState, String> {
494    let pending = state
495        .pending
496        .take()
497        .ok_or_else(|| "no prepared telemetry batch to acknowledge".to_string())?;
498    if pending.batch != *batch {
499        return Err("telemetry acknowledgement does not match pending batch".to_string());
500    }
501    state.process_nonce = pending.process_nonce;
502    state.acknowledged = pending.observed;
503    state.last_sent_bucket = batch
504        .events
505        .first()
506        .map(|event| event.timestamp_bucket.clone());
507    Ok(state)
508}
509
510pub fn last_sent_bucket() -> Option<String> {
511    load_state()
512        .and_then(state_for_current_identity)
513        .ok()
514        .and_then(|state| state.last_sent_bucket)
515}
516
517/// UTC bucket shared by payload generation and the background caller's precheck.
518pub(crate) fn current_send_bucket() -> String {
519    send_clock().0
520}
521
522/// UTC day bucket and unix time from one clock reading.
523fn send_clock() -> (String, i64) {
524    #[cfg(test)]
525    if let Some(clock) = TEST_CLOCK
526        .lock()
527        .unwrap_or_else(std::sync::PoisonError::into_inner)
528        .clone()
529    {
530        return clock;
531    }
532    let now = chrono::Utc::now();
533    (now.format("%Y-%m-%d").to_string(), now.timestamp())
534}
535
536/// Fixed clock for tests; `None` uses the system clock.
537#[cfg(test)]
538static TEST_CLOCK: Mutex<Option<(String, i64)>> = Mutex::new(None);
539
540/// Pin the send clock until the guard drops.
541#[cfg(test)]
542struct TestClockGuard;
543
544#[cfg(test)]
545impl TestClockGuard {
546    fn set(bucket: &str, unix: i64) -> Self {
547        *TEST_CLOCK
548            .lock()
549            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some((bucket.to_string(), unix));
550        Self
551    }
552}
553
554#[cfg(test)]
555impl Drop for TestClockGuard {
556    fn drop(&mut self) {
557        *TEST_CLOCK
558            .lock()
559            .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
560    }
561}
562
563fn state_for_current_identity(mut state: AggregateState) -> Result<AggregateState, String> {
564    let (current, _) = installation_id::get_or_create_identity()
565        .map_err(|error| format!("installation ID unavailable: {error}"))?;
566    let pending_mismatch = state.pending.as_ref().is_some_and(|pending| {
567        let pending_id = batch_installation_id(&pending.batch);
568        pending_id.is_empty() || pending_id != current
569    });
570    if pending_mismatch || (!state.installation_id.is_empty() && state.installation_id != current) {
571        state = AggregateState {
572            installation_id: current,
573            ..AggregateState::default()
574        };
575    } else if state.installation_id.is_empty() {
576        state.installation_id = current;
577    }
578    Ok(state)
579}
580
581fn batch_installation_id(batch: &TelemetryBatchV2) -> &str {
582    batch
583        .events
584        .first()
585        .map_or("", |event| event.installation_id.as_str())
586}
587
588/// Build today's cumulative totals under `today`, followed by every closed day
589/// with unsent activity (oldest first) under its own bucket, as many as fit in
590/// one batch. Today leads so the first event names the send's bucket. Returns
591/// the exact totals included per day, to be recorded as sent on success.
592fn build_batch(
593    one_shots: &OneShotState,
594    today: &str,
595) -> Result<(TelemetryBatchV2, BTreeMap<String, QueuedOneShots>), String> {
596    let (installation_id, deletion_token) = installation_id::get_or_create_identity()
597        .map_err(|error| format!("installation ID unavailable: {error}"))?;
598    let deletion_token_hash = hex::encode(sha2::Sha256::digest(deletion_token.as_bytes()));
599    let distribution_channel = distribution_channel();
600    let client_family = client_family();
601    let build_day = |day: &str, totals: &QueuedOneShots| {
602        build_daily_aggregate(
603            installation_id.clone(),
604            deletion_token_hash.clone(),
605            day.to_string(),
606            distribution_channel,
607            client_family,
608            totals,
609        )
610    };
611    let today_totals = one_shots
612        .days
613        .get(today)
614        .map(|day| day.totals.clone())
615        .unwrap_or_default();
616    let mut batch = build_day(today, &today_totals)?;
617    if let Some(history) = chrono::NaiveDate::parse_from_str(today, "%Y-%m-%d")
618        .ok()
619        .and_then(history::usage_history)
620    {
621        let event = envelope_like(&batch.events[0], TelemetryEventV2::UsageHistory(history));
622        batch.events.push(event);
623    }
624    let mut included = BTreeMap::from([(today.to_string(), today_totals)]);
625    for (day, totals) in one_shots
626        .days
627        .range::<str, _>(before(today))
628        .filter(|(_, day)| day.unsent())
629    {
630        let closed = build_day(day, &totals.totals)?;
631        if batch.events.len() + closed.events.len() > MAX_BATCH_EVENTS {
632            break;
633        }
634        batch.events.extend(closed.events);
635        included.insert(day.clone(), totals.totals.clone());
636    }
637    batch
638        .validate()
639        .map_err(|error| format!("invalid telemetry batch: {error:?}"))?;
640    Ok((batch, included))
641}
642
643fn build_daily_aggregate(
644    installation_id: String,
645    deletion_token_hash: String,
646    timestamp_bucket: String,
647    distribution_channel: DistributionChannel,
648    client_family: ClientFamily,
649    queued: &QueuedOneShots,
650) -> Result<TelemetryBatchV2, String> {
651    let observed = &queued.counters;
652    let baseline = CounterCheckpoint::default();
653    let latency_counts = bounded_histogram_delta(
654        &observed.tool_latency_buckets,
655        &baseline.tool_latency_buckets,
656    );
657    let calls = latency_counts.iter().sum();
658    debug_assert_eq!(
659        calls,
660        observed
661            .tool_calls
662            .saturating_sub(baseline.tool_calls)
663            .min(MAX_COUNT)
664    );
665    let failures = observed
666        .tool_failures
667        .saturating_sub(baseline.tool_failures)
668        .min(calls);
669    let duration = observed
670        .session_uptime_secs
671        .saturating_sub(baseline.session_uptime_secs)
672        .min(MAX_COUNT);
673    let mut batch = build_daily_heartbeat(
674        installation_id,
675        deletion_token_hash,
676        timestamp_bucket,
677        distribution_channel,
678        client_family,
679    )?;
680    let common = batch.events[0].clone();
681    batch.events.push(envelope_like(
682        &common,
683        TelemetryEventV2::SetupProfile(setup_profile()),
684    ));
685    batch.events.push(envelope_like(
686        &common,
687        TelemetryEventV2::SessionAggregate(SessionMetrics {
688            sessions: 1,
689            duration_seconds: single_observation_histogram(
690                duration,
691                &[
692                    60,
693                    300,
694                    900,
695                    3_600,
696                    14_400,
697                    86_400,
698                    604_800,
699                    31_536_000,
700                    i64::MAX as u64,
701                ],
702                1,
703            ),
704        }),
705    ));
706    batch.events.push(envelope_like(
707        &common,
708        TelemetryEventV2::ToolUsageAggregate(ToolUsageMetrics {
709            calls,
710            failures,
711            latency_milliseconds: Histogram {
712                upper_bounds: crate::core::telemetry::TOOL_LATENCY_BUCKET_UPPER_MS.to_vec(),
713                counts: latency_counts.to_vec(),
714            },
715            tokens: Some(TokenMetrics {
716                original: observed.tokens_input.min(MAX_COUNT),
717                delivered: observed.tokens_output.min(MAX_COUNT),
718            }),
719        }),
720    ));
721    let tools = tool_call_deltas(&observed.tools, &baseline.tools, &observed.failure_messages);
722    if !tools.is_empty() {
723        batch.events.push(envelope_like(
724            &common,
725            TelemetryEventV2::ToolCallAggregate(ToolCallMetrics { tools }),
726        ));
727    }
728    if queued.setup_completed {
729        batch.events.push(envelope_like(
730            &common,
731            TelemetryEventV2::SetupCompleted(OccurrenceMetrics { count: 1 }),
732        ));
733    }
734    if queued.integrations_detected > 0 {
735        batch.events.push(envelope_like(
736            &common,
737            TelemetryEventV2::IntegrationDetected(OccurrenceMetrics {
738                count: queued.integrations_detected.min(MAX_COUNT),
739            }),
740        ));
741    }
742    if let Some(transition) = queued.version_upgrade {
743        batch.events.push(envelope_like(
744            &common,
745            TelemetryEventV2::VersionUpgrade(VersionUpgradeMetrics {
746                from_major: transition.from_major,
747                to_major: transition.to_major,
748            }),
749        ));
750    }
751    if queued.sync.attempts > 0 {
752        batch.events.push(envelope_like(
753            &common,
754            TelemetryEventV2::SyncAggregate(queued.sync.clone()),
755        ));
756    }
757    if queued.autopilot.admitted > 0 || queued.autopilot.denied > 0 || queued.autopilot.fallback > 0
758    {
759        batch.events.push(envelope_like(
760            &common,
761            TelemetryEventV2::AutopilotAggregate(queued.autopilot.clone()),
762        ));
763    }
764    if queued.autopilot_fallback.fallback > 0 {
765        batch.events.push(envelope_like(
766            &common,
767            TelemetryEventV2::AutopilotFallbackAggregate(queued.autopilot_fallback.clone()),
768        ));
769    }
770    if queued.checkout_started > 0 {
771        batch.events.push(envelope_like(
772            &common,
773            TelemetryEventV2::CheckoutStarted(OccurrenceMetrics {
774                count: queued.checkout_started,
775            }),
776        ));
777    }
778    for (category, count) in ERROR_CATEGORIES
779        .into_iter()
780        .zip(queued.error_categories)
781        .chain([(ErrorCategory::Command, queued.command_errors)])
782    {
783        let count = count.min(MAX_COUNT);
784        if count > 0 {
785            batch.events.push(envelope_like(
786                &common,
787                TelemetryEventV2::ErrorCategoryAggregate(ErrorMetrics { category, count }),
788            ));
789        }
790    }
791    if let Some(features) = feature_metrics(&queued.features) {
792        batch.events.push(envelope_like(
793            &common,
794            TelemetryEventV2::FeatureAggregate(features),
795        ));
796    }
797    batch
798        .validate()
799        .map_err(|error| format!("invalid telemetry batch: {error:?}"))?;
800    Ok(batch)
801}
802
803fn bounded_histogram_delta<const N: usize>(observed: &[u64; N], baseline: &[u64; N]) -> [u64; N] {
804    let mut remaining = MAX_COUNT;
805    std::array::from_fn(|index| {
806        let count = observed[index]
807            .saturating_sub(baseline[index])
808            .min(remaining);
809        remaining -= count;
810        count
811    })
812}
813
814/// Per-tool calls since the baseline. Names failing the contract format are
815/// dropped; past [`MAX_TOOL_ENTRIES`] the most-called tools are kept. The
816/// result is sorted by name, as the contract requires.
817fn tool_call_deltas(
818    observed: &BTreeMap<String, ToolCounterCheckpoint>,
819    baseline: &BTreeMap<String, ToolCounterCheckpoint>,
820    messages: &BTreeMap<String, u64>,
821) -> Vec<ToolCallCount> {
822    let mut tools: Vec<ToolCallCount> = observed
823        .iter()
824        .filter(|(tool, _)| valid_tool_name(tool))
825        .filter_map(|(tool, counter)| {
826            let base = baseline.get(tool).copied().unwrap_or_default();
827            let calls = counter.calls.saturating_sub(base.calls).min(MAX_COUNT);
828            let failures = counter.failures.saturating_sub(base.failures).min(calls);
829            (calls > 0).then(|| ToolCallCount {
830                tool: tool.clone(),
831                calls,
832                failures,
833                latency_milliseconds_total: Some(
834                    (counter.latency_us.saturating_sub(base.latency_us) / 1_000).min(MAX_COUNT),
835                ),
836                failure_kinds: wire_kinds(
837                    &sub_kinds(&counter.failure_kinds, &base.failure_kinds),
838                    failures,
839                ),
840                failure_messages: wire_messages(messages, tool, failures),
841            })
842        })
843        .collect();
844    if tools.len() > MAX_TOOL_ENTRIES {
845        tools.sort_by(|a, b| b.calls.cmp(&a.calls).then_with(|| a.tool.cmp(&b.tool)));
846        tools.truncate(MAX_TOOL_ENTRIES);
847        tools.sort_by(|a, b| a.tool.cmp(&b.tool));
848    }
849    tools
850}
851
852fn envelope_like(template: &TelemetryEnvelopeV2, event: TelemetryEventV2) -> TelemetryEnvelopeV2 {
853    TelemetryEnvelopeV2 {
854        schema_version: template.schema_version,
855        timestamp_bucket: template.timestamp_bucket.clone(),
856        installation_id: template.installation_id.clone(),
857        account_id: template.account_id.clone(),
858        organization_id: template.organization_id.clone(),
859        app_version: template.app_version.clone(),
860        event,
861    }
862}
863
864fn single_observation_histogram(value: u64, bounds: &[u64], count: u64) -> Histogram {
865    let mut counts = vec![0; bounds.len()];
866    let index = bounds
867        .iter()
868        .position(|bound| value <= *bound)
869        .unwrap_or(bounds.len() - 1);
870    counts[index] = count.min(MAX_COUNT);
871    Histogram {
872        upper_bounds: bounds.to_vec(),
873        counts,
874    }
875}
876
877fn current_checkpoint() -> CounterCheckpoint {
878    let snapshot = crate::core::telemetry::global_metrics().daily_telemetry_snapshot();
879    CounterCheckpoint {
880        tool_calls: snapshot.tool_calls,
881        tool_failures: snapshot.tool_failures,
882        tool_latency_buckets: snapshot.tool_latency_buckets,
883        session_uptime_secs: snapshot.session_uptime_secs,
884        tools: snapshot
885            .per_tool
886            .into_iter()
887            .map(|(tool, counter)| {
888                (
889                    tool.to_string(),
890                    ToolCounterCheckpoint {
891                        calls: counter.calls,
892                        failures: counter.failures,
893                        latency_us: counter.latency_us,
894                        failure_kinds: counter.failure_kinds,
895                    },
896                )
897            })
898            .collect(),
899        tokens_input: snapshot.tokens_input,
900        tokens_output: snapshot.tokens_output,
901        failure_messages: snapshot
902            .failure_templates
903            .into_iter()
904            .map(|((tool, template), count)| (format!("{tool}\t{template}"), count))
905            .collect(),
906    }
907}
908
909fn process_nonce() -> &'static str {
910    static NONCE: OnceLock<String> = OnceLock::new();
911    NONCE.get_or_init(|| uuid::Uuid::new_v4().to_string())
912}
913
914/// This process's counters already folded into each sidecar, keyed by path so
915/// an isolated state directory starts from zero.
916fn folded_baselines() -> &'static Mutex<HashMap<PathBuf, CounterCheckpoint>> {
917    static FOLDED: OnceLock<Mutex<HashMap<PathBuf, CounterCheckpoint>>> = OnceLock::new();
918    FOLDED.get_or_init(Mutex::default)
919}
920
921/// Add this process's not-yet-folded counters to the totals of `day` in memory
922/// and return the observed checkpoint. Callers that persist the result must
923/// then [`mark_folded`] it while still holding the sidecar lock.
924fn fold_process_counters(
925    path: &std::path::Path,
926    one_shots: &mut OneShotState,
927    day: &str,
928) -> CounterCheckpoint {
929    let observed = current_checkpoint();
930    let folded = folded_baselines()
931        .lock()
932        .unwrap_or_else(std::sync::PoisonError::into_inner)
933        .get(path)
934        .cloned()
935        .unwrap_or_default();
936    let delta = counter_delta(&observed, &folded);
937    if delta != CounterCheckpoint::default() {
938        add_counters(&mut day_totals(one_shots, day).counters, &delta);
939    }
940    observed
941}
942
943fn mark_folded(path: &std::path::Path, observed: CounterCheckpoint) {
944    folded_baselines()
945        .lock()
946        .unwrap_or_else(std::sync::PoisonError::into_inner)
947        .insert(path.to_path_buf(), observed);
948}
949
950/// Persist this process's unsent tool counters so they survive process exit
951/// and reach the next daily batch. While telemetry is disabled the counters
952/// are skipped instead, so re-enabling never back-fills opted-out activity.
953pub fn persist_process_counters() -> Result<(), String> {
954    let path = one_shot_path()?;
955    if !telemetry_collection_eligible() {
956        mark_folded(&path, current_checkpoint());
957        return Ok(());
958    }
959    ensure_parent(&path)?;
960    let lock = open_sidecar_lock(&path)?;
961    lock_telemetry_file(&lock, "one-shot")?;
962    let mut one_shots = one_shots_for_current_identity(load_one_shots_at(&path)?)?;
963    let before = one_shots.clone();
964    let observed = fold_process_counters(&path, &mut one_shots, &current_send_bucket());
965    if one_shots.days != before.days || one_shots.queued != before.queued {
966        write_one_shots(&path, &one_shots)?;
967    }
968    mark_folded(&path, observed);
969    Ok(())
970}
971
972fn state_path() -> Result<PathBuf, String> {
973    crate::core::paths::state_dir().map(|dir| dir.join("telemetry_v2_aggregate.json"))
974}
975
976fn one_shot_path() -> Result<PathBuf, String> {
977    crate::core::paths::state_dir().map(|dir| dir.join("telemetry_v2_one_shots.json"))
978}
979
980pub fn record_setup_completion(
981    integration_ids: impl IntoIterator<Item = String>,
982) -> Result<(), String> {
983    if !telemetry_collection_eligible() {
984        return Ok(());
985    }
986    with_locked_one_shots(|mut state| {
987        let mut setup_completed = false;
988        if !state.setup_recorded {
989            state.setup_recorded = true;
990            setup_completed = true;
991        }
992        let mut inserted = 0_u64;
993        for integration_id in integration_ids {
994            if state.configured_integrations.len() >= 64 {
995                break;
996            }
997            if integration_id.is_empty() || integration_id.len() > 128 {
998                continue;
999            }
1000            let stable_id = hex::encode(sha2::Sha256::digest(integration_id.as_bytes()));
1001            if state.configured_integrations.insert(stable_id) {
1002                inserted += 1;
1003            }
1004        }
1005        let today = today_totals(&mut state);
1006        today.setup_completed |= setup_completed;
1007        today.integrations_detected = today
1008            .integrations_detected
1009            .saturating_add(inserted)
1010            .min(MAX_COUNT);
1011        Ok((state, ()))
1012    })
1013}
1014
1015pub fn record_sync_result(success: bool) -> Result<(), String> {
1016    if !telemetry_collection_eligible() {
1017        return Ok(());
1018    }
1019    with_locked_one_shots(|mut state| {
1020        let sync = &mut today_totals(&mut state).sync;
1021        if sync.attempts >= MAX_COUNT {
1022            return Ok((state, ()));
1023        }
1024        sync.attempts += 1;
1025        if success {
1026            sync.successes = sync.successes.saturating_add(1).min(sync.attempts);
1027        } else {
1028            sync.failures = sync
1029                .failures
1030                .saturating_add(1)
1031                .min(sync.attempts.saturating_sub(sync.successes));
1032        }
1033        Ok((state, ()))
1034    })
1035}
1036
1037pub fn record_autopilot_decisions(admitted: u64, denied: u64) -> Result<(), String> {
1038    if !telemetry_collection_eligible() {
1039        return Ok(());
1040    }
1041    with_locked_one_shots(|mut state| {
1042        add_decisions(
1043            &mut today_totals(&mut state).autopilot,
1044            &DecisionMetrics {
1045                admitted,
1046                denied,
1047                fallback: 0,
1048            },
1049        );
1050        Ok((state, ()))
1051    })
1052}
1053
1054pub fn record_autopilot_fallback() -> Result<(), String> {
1055    if !telemetry_collection_eligible() {
1056        return Ok(());
1057    }
1058    with_locked_one_shots(|mut state| {
1059        let today = today_totals(&mut state);
1060        let fallback = DecisionMetrics {
1061            admitted: 0,
1062            denied: 0,
1063            fallback: 1,
1064        };
1065        add_decisions(&mut today.autopilot, &fallback);
1066        add_decisions(&mut today.autopilot_fallback, &fallback);
1067        Ok((state, ()))
1068    })
1069}
1070
1071pub fn record_checkout_started() -> Result<(), String> {
1072    if !telemetry_collection_eligible() {
1073        return Ok(());
1074    }
1075    with_locked_one_shots(|mut state| {
1076        let today = today_totals(&mut state);
1077        today.checkout_started = today.checkout_started.saturating_add(1).min(MAX_COUNT);
1078        Ok((state, ()))
1079    })
1080}
1081
1082/// Counts one use of a registry feature (`core::telemetry_features`) for
1083/// today; `ok = false` also counts a failure. Invalid codes are ignored.
1084pub fn record_feature(code: &str, ok: bool) -> Result<(), String> {
1085    record_feature_counts(&[(code, 1, u64::from(!ok))])
1086}
1087
1088/// Adds `(code, count, failures)` tallies for today in one locked write.
1089/// Invalid codes and empty tallies are ignored.
1090pub fn record_feature_counts(entries: &[(&str, u64, u64)]) -> Result<(), String> {
1091    let entries: Vec<_> = entries
1092        .iter()
1093        .filter(|(code, count, _)| *count > 0 && valid_feature_code(code))
1094        .collect();
1095    if entries.is_empty() || !telemetry_collection_eligible() {
1096        return Ok(());
1097    }
1098    with_locked_one_shots(|mut state| {
1099        let features = &mut today_totals(&mut state).features;
1100        for (code, count, failures) in entries {
1101            let (count, failures) = (*count, *failures);
1102            add_feature(features, code, FeatureTally { count, failures });
1103        }
1104        Ok((state, ()))
1105    })
1106}
1107
1108const ERROR_CATEGORIES: [ErrorCategory; 8] = [
1109    ErrorCategory::Authentication,
1110    ErrorCategory::Authorization,
1111    ErrorCategory::Configuration,
1112    ErrorCategory::Network,
1113    ErrorCategory::Provider,
1114    ErrorCategory::Timeout,
1115    ErrorCategory::Validation,
1116    ErrorCategory::Internal,
1117];
1118
1119pub fn record_error_category(category: ErrorCategory) -> Result<(), String> {
1120    record_error_category_inner(category)
1121}
1122
1123fn record_error_category_inner(category: ErrorCategory) -> Result<(), String> {
1124    if !telemetry_collection_eligible() {
1125        return Ok(());
1126    }
1127    let index = ERROR_CATEGORIES
1128        .iter()
1129        .position(|candidate| *candidate == category);
1130    with_locked_one_shots(|mut state| {
1131        let totals = today_totals(&mut state);
1132        let count = match index {
1133            Some(index) => &mut totals.error_categories[index],
1134            None => &mut totals.command_errors,
1135        };
1136        *count = count.saturating_add(1).min(MAX_COUNT);
1137        Ok((state, ()))
1138    })
1139}
1140
1141/// Collection stays off whenever the opt-out cannot be read: an unreadable or
1142/// corrupt config must never be mistaken for consent.
1143fn telemetry_collection_eligible() -> bool {
1144    let Ok(config) = crate::core::config::Config::try_load_global() else {
1145        return false;
1146    };
1147    let do_not_track = crate::core::host_env::var("DO_NOT_TRACK");
1148    let telemetry_override = crate::core::host_env::var("LEAN_CTX_TELEMETRY");
1149    !config.telemetry.explicitly_disabled()
1150        && !crate::core::config::TelemetryConfig::environment_disables(
1151            do_not_track.as_deref(),
1152            telemetry_override.as_deref(),
1153        )
1154        && !crate::core::telemetry_consent::running_in_ci()
1155}
1156
1157pub fn record_current_version() -> Result<(), String> {
1158    record_current_version_value(env!("CARGO_PKG_VERSION"))
1159}
1160
1161fn record_current_version_value(version: &str) -> Result<(), String> {
1162    let current = parse_major(version)
1163        .ok_or_else(|| "current app version has no valid major component".to_string())?;
1164    with_locked_one_shots(|mut state| {
1165        let previous = match state.observed_major {
1166            Some(previous) => Some(previous),
1167            None => crate::core::telemetry_ledger::latest_valid_version()?
1168                .as_deref()
1169                .and_then(parse_major),
1170        };
1171        match previous {
1172            Some(previous) if current > previous => {
1173                // Several upgrades within one day collapse into one transition.
1174                let today = today_totals(&mut state);
1175                let from_major = today
1176                    .version_upgrade
1177                    .map_or(previous, |transition| transition.from_major);
1178                today.version_upgrade = Some(VersionTransition {
1179                    from_major,
1180                    to_major: current,
1181                });
1182                state.observed_major = Some(current);
1183            }
1184            Some(previous) => state.observed_major = Some(previous.max(current)),
1185            None => state.observed_major = Some(current),
1186        }
1187        Ok((state, ()))
1188    })
1189}
1190
1191fn parse_major(version: &str) -> Option<u16> {
1192    version.split('.').next()?.parse().ok()
1193}
1194
1195pub fn purge_local_state() -> Result<(), String> {
1196    purge_local_state_then(|| Ok(()))
1197}
1198
1199pub fn purge_local_state_then<T>(
1200    operation: impl FnOnce() -> Result<T, String>,
1201) -> Result<T, String> {
1202    let path = state_path()?;
1203    ensure_parent(&path)?;
1204    let lock = open_state_lock(&path)?;
1205    lock_telemetry_file(&lock, "aggregate")?;
1206    let one_shot_path = one_shot_path()?;
1207    ensure_parent(&one_shot_path)?;
1208    let one_shot_lock = open_sidecar_lock(&one_shot_path)?;
1209    lock_telemetry_file(&one_shot_lock, "one-shot")?;
1210    remove_state_file(&path, "aggregate")?;
1211    remove_state_file(&one_shot_path, "one-shot")?;
1212    operation()
1213}
1214
1215pub fn rotate_identity_state_then<T>(
1216    operation: impl FnOnce() -> Result<T, String>,
1217) -> Result<T, String> {
1218    let path = state_path()?;
1219    let one_shot_path = one_shot_path()?;
1220    ensure_parent(&path)?;
1221    let lock = open_state_lock(&path)?;
1222    lock_telemetry_file(&lock, "aggregate")?;
1223    let one_shot_lock = open_sidecar_lock(&one_shot_path)?;
1224    lock_telemetry_file(&one_shot_lock, "one-shot")?;
1225    let mut one_shots = one_shots_for_current_identity(load_one_shots_at(&one_shot_path)?)?;
1226    write_one_shots(&one_shot_path, &one_shots)?;
1227    let value = operation()?;
1228    one_shots.installation_id = installation_id::get_or_create()
1229        .map_err(|error| format!("installation ID unavailable after rotation: {error}"))?;
1230    requeue_for_new_identity(&mut one_shots);
1231    remove_state_file(&path, "aggregate")?;
1232    write_one_shots(&one_shot_path, &one_shots)?;
1233    Ok(value)
1234}
1235
1236/// A new identity starts with no history, so only setup facts carry over:
1237/// its first batch reports the existing setup as that identity's own.
1238fn requeue_for_new_identity(state: &mut OneShotState) {
1239    state.queued = QueuedOneShots::default();
1240    state.days.clear();
1241    state.last_acknowledged_batch = None;
1242    let setup_completed = state.setup_recorded;
1243    let integrations = state
1244        .configured_integrations
1245        .len()
1246        .try_into()
1247        .unwrap_or(MAX_COUNT)
1248        .min(MAX_COUNT);
1249    let today = today_totals(state);
1250    today.setup_completed = setup_completed;
1251    today.integrations_detected = integrations;
1252}
1253
1254/// Totals for the current UTC day, created on first use.
1255fn today_totals(state: &mut OneShotState) -> &mut QueuedOneShots {
1256    day_totals(state, &current_send_bucket())
1257}
1258
1259fn day_totals<'a>(state: &'a mut OneShotState, day: &str) -> &'a mut QueuedOneShots {
1260    &mut state.days.entry(day.to_string()).or_default().totals
1261}
1262
1263fn remove_state_file(path: &std::path::Path, kind: &str) -> Result<(), String> {
1264    match std::fs::remove_file(path) {
1265        Ok(()) => Ok(()),
1266        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1267        Err(error) => Err(format!("cannot purge telemetry {kind} state: {error}")),
1268    }
1269}
1270
1271fn load_state() -> Result<AggregateState, String> {
1272    let path = state_path()?;
1273    load_state_at(&path)
1274}
1275
1276fn load_state_at(path: &std::path::Path) -> Result<AggregateState, String> {
1277    match std::fs::read(path) {
1278        Ok(bytes) => serde_json::from_slice(&bytes)
1279            .map_err(|error| format!("invalid telemetry aggregate state: {error}")),
1280        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(AggregateState::default()),
1281        Err(error) => Err(format!("cannot read telemetry aggregate state: {error}")),
1282    }
1283}
1284
1285fn load_one_shots_at(path: &std::path::Path) -> Result<OneShotState, String> {
1286    match std::fs::read(path) {
1287        Ok(bytes) => serde_json::from_slice(&bytes)
1288            .map_err(|error| format!("invalid telemetry one-shot state: {error}")),
1289        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(OneShotState::default()),
1290        Err(error) => Err(format!("cannot read telemetry one-shot state: {error}")),
1291    }
1292}
1293
1294fn one_shots_for_current_identity(mut state: OneShotState) -> Result<OneShotState, String> {
1295    let current = installation_id::get_or_create()
1296        .map_err(|error| format!("installation ID unavailable: {error}"))?;
1297    if state.installation_id.is_empty() {
1298        state.installation_id = current;
1299    } else if state.installation_id != current {
1300        state.installation_id = current;
1301        requeue_for_new_identity(&mut state);
1302    }
1303    normalize_days(&mut state, &current_send_bucket());
1304    Ok(state)
1305}
1306
1307/// Range over the day keys strictly before `day` (closed days).
1308fn before(day: &str) -> (std::ops::Bound<&str>, std::ops::Bound<&str>) {
1309    (std::ops::Bound::Unbounded, std::ops::Bound::Excluded(day))
1310}
1311
1312/// Migrate the pre-daily-totals queue into today and drop closed days the
1313/// server already holds, keeping the most recent unsent ones.
1314fn normalize_days(state: &mut OneShotState, today: &str) {
1315    if !state.queued.is_empty() {
1316        let legacy = std::mem::take(&mut state.queued);
1317        day_totals(state, today).absorb(&legacy);
1318    }
1319    state
1320        .days
1321        .retain(|day, totals| day.as_str() >= today || totals.unsent());
1322    let closed = state.days.range::<str, _>(before(today)).count();
1323    let stale: Vec<String> = state
1324        .days
1325        .keys()
1326        .take(closed.saturating_sub(RETAINED_CLOSED_DAYS))
1327        .cloned()
1328        .collect();
1329    for day in stale {
1330        state.days.remove(&day);
1331    }
1332}
1333
1334fn with_locked_one_shots<T>(
1335    operation: impl FnOnce(OneShotState) -> Result<(OneShotState, T), String>,
1336) -> Result<T, String> {
1337    let path = one_shot_path()?;
1338    ensure_parent(&path)?;
1339    let lock = open_sidecar_lock(&path)?;
1340    lock_telemetry_file(&lock, "one-shot")?;
1341    let state = one_shots_for_current_identity(load_one_shots_at(&path)?)?;
1342    let (state, value) = operation(state)?;
1343    write_one_shots(&path, &state)?;
1344    Ok(value)
1345}
1346
1347/// Record the exact per-day totals the server accepted. Totals that grew while
1348/// the batch was in flight stay unsent and go out with the next send.
1349fn acknowledge_one_shots_at(
1350    path: &std::path::Path,
1351    included: &BTreeMap<String, QueuedOneShots>,
1352    acknowledgement_id: &str,
1353) -> Result<(), String> {
1354    ensure_parent(path)?;
1355    let lock = open_sidecar_lock(path)?;
1356    lock_telemetry_file(&lock, "one-shot")?;
1357    let mut state = one_shots_for_current_identity(load_one_shots_at(path)?)?;
1358    if state.last_acknowledged_batch.as_deref() == Some(acknowledgement_id) {
1359        return Ok(());
1360    }
1361    // A day pruned or reset by an identity change in the meantime stays gone.
1362    for (day, sent) in included {
1363        if let Some(totals) = state.days.get_mut(day) {
1364            totals.sent = sent.clone();
1365        }
1366    }
1367    state.last_acknowledged_batch = Some(acknowledgement_id.to_string());
1368    normalize_days(&mut state, &current_send_bucket());
1369    write_one_shots(path, &state)
1370}
1371
1372#[cfg(test)]
1373fn with_locked_state<T>(
1374    operation: impl FnOnce(AggregateState) -> Result<(AggregateState, T), String>,
1375) -> Result<T, String> {
1376    let path = state_path()?;
1377    ensure_parent(&path)?;
1378    let lock = open_state_lock(&path)?;
1379    lock.lock_exclusive()
1380        .map_err(|error| format!("cannot lock telemetry aggregate state: {error}"))?;
1381    let (state, value) = operation(load_state_at(&path)?)?;
1382    write_state(&path, &state)?;
1383    Ok(value)
1384}
1385
1386fn ensure_parent(path: &std::path::Path) -> Result<(), String> {
1387    let parent = path
1388        .parent()
1389        .ok_or_else(|| "telemetry state path has no parent".to_string())?;
1390    std::fs::create_dir_all(parent)
1391        .map_err(|error| format!("cannot create telemetry state directory: {error}"))
1392}
1393
1394fn write_state(path: &std::path::Path, state: &AggregateState) -> Result<(), String> {
1395    let bytes = serde_json::to_vec(&state)
1396        .map_err(|error| format!("cannot serialize telemetry aggregate state: {error}"))?;
1397    #[cfg(unix)]
1398    let permissions = {
1399        use std::os::unix::fs::PermissionsExt;
1400        Some(std::fs::Permissions::from_mode(0o600))
1401    };
1402    #[cfg(not(unix))]
1403    let permissions: Option<std::fs::Permissions> = None;
1404    crate::core::atomic_fs::try_atomic_write(&path, &bytes, permissions.as_ref())
1405        .map_err(|error| format!("cannot persist telemetry aggregate state: {error}"))
1406}
1407
1408fn write_one_shots(path: &std::path::Path, state: &OneShotState) -> Result<(), String> {
1409    let bytes = serde_json::to_vec(state)
1410        .map_err(|error| format!("cannot serialize telemetry one-shot state: {error}"))?;
1411    #[cfg(unix)]
1412    let permissions = {
1413        use std::os::unix::fs::PermissionsExt;
1414        Some(std::fs::Permissions::from_mode(0o600))
1415    };
1416    #[cfg(not(unix))]
1417    let permissions: Option<std::fs::Permissions> = None;
1418    crate::core::atomic_fs::try_atomic_write(path, &bytes, permissions.as_ref())
1419        .map_err(|error| format!("cannot persist telemetry one-shot state: {error}"))
1420}
1421
1422fn open_state_lock(path: &std::path::Path) -> Result<std::fs::File, String> {
1423    std::fs::OpenOptions::new()
1424        .create(true)
1425        .truncate(false)
1426        .read(true)
1427        .write(true)
1428        .open(path.with_extension("lock"))
1429        .map_err(|error| format!("cannot open telemetry state lock: {error}"))
1430}
1431
1432fn open_sidecar_lock(path: &std::path::Path) -> Result<std::fs::File, String> {
1433    open_state_lock(path)
1434}
1435
1436/// Contention fails without mutating state; failed acknowledgements keep the
1437/// frozen batch retryable. This bounds acquisition, not filesystem I/O.
1438/// Tests use the production bound: a contender must outwait a lease holder
1439/// even under coverage instrumentation, where 150 ms timed out.
1440const SEND_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(750);
1441const SEND_LOCK_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25);
1442
1443/// Shared acquisition for aggregate, one-shot and ledger locks, in that order.
1444pub(super) fn lock_telemetry_file(file: &std::fs::File, kind: &str) -> Result<(), String> {
1445    let deadline = std::time::Instant::now() + SEND_LOCK_TIMEOUT;
1446    loop {
1447        match file.try_lock_exclusive() {
1448            Ok(()) => return Ok(()),
1449            Err(error) if super::file_lock::is_contended(&error) => {
1450                if std::time::Instant::now() >= deadline {
1451                    return Err(format!(
1452                        "telemetry {kind} lock timed out after {}ms; another operation is active",
1453                        SEND_LOCK_TIMEOUT.as_millis()
1454                    ));
1455                }
1456                std::thread::sleep(SEND_LOCK_RETRY_INTERVAL);
1457            }
1458            Err(error) => return Err(format!("cannot lock telemetry {kind} state: {error}")),
1459        }
1460    }
1461}
1462
1463pub fn build_daily_heartbeat(
1464    installation_id: String,
1465    deletion_token_hash: String,
1466    timestamp_bucket: String,
1467    distribution_channel: DistributionChannel,
1468    client_family: ClientFamily,
1469) -> Result<TelemetryBatchV2, String> {
1470    let install_age = environment::install_age(std::time::SystemTime::now());
1471    let active_days = environment::active_days(&installation_id, &timestamp_bucket);
1472    let batch = TelemetryBatchV2 {
1473        schema_version: SCHEMA_VERSION,
1474        deletion_token_hash,
1475        events: vec![TelemetryEnvelopeV2 {
1476            schema_version: SCHEMA_VERSION,
1477            timestamp_bucket,
1478            installation_id,
1479            account_id: None,
1480            organization_id: None,
1481            app_version: env!("CARGO_PKG_VERSION").to_string(),
1482            event: TelemetryEventV2::Heartbeat(HeartbeatMetrics {
1483                distribution_channel,
1484                client_family,
1485                operating_system: OperatingSystem::current(),
1486                architecture: Architecture::current(),
1487                install_age,
1488                active_days: Some(active_days),
1489                runtime_environment: Some(environment::runtime_environment()),
1490            }),
1491        }],
1492    };
1493    batch
1494        .validate()
1495        .map_err(|error| format!("invalid telemetry batch: {error:?}"))?;
1496    Ok(batch)
1497}
1498
1499#[cfg(test)]
1500mod tests;