1use 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
37pub const DAILY_SEND_CAP: u32 = 48;
41const RETRY_BACKOFF_SECS: i64 = 60;
43const RESEND_INTERVAL_SECS: i64 = 30 * 60;
47const MAX_SEND_INTERVAL_SECS: i64 = 2 * 60 * 60;
49const EXIT_RESEND_INTERVAL_SECS: i64 = 5 * 60;
51const RETAINED_CLOSED_DAYS: usize = 7;
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SendTrigger {
57 Periodic,
59 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 #[serde(default)]
72 tools: BTreeMap<String, ToolCounterCheckpoint>,
73 #[serde(default)]
75 tokens_input: u64,
76 #[serde(default)]
77 tokens_output: u64,
78 #[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 process_nonce: String,
102 acknowledged: CounterCheckpoint,
103 pending: Option<PendingBatch>,
104 #[serde(default)]
106 last_sent_bucket: Option<String>,
107 #[serde(default)]
109 last_attempt_unix: Option<i64>,
110 #[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 #[serde(default, skip_serializing_if = "QueuedOneShots::is_empty")]
130 queued: QueuedOneShots,
131 #[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 #[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 #[serde(default)]
170 command_errors: u64,
171 #[serde(default)]
174 counters: CounterCheckpoint,
175 #[serde(default)]
177 features: BTreeMap<String, FeatureTally>,
178}
179
180impl QueuedOneShots {
181 fn is_empty(&self) -> bool {
182 *self == Self::default()
183 }
184
185 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 #[serde(default, skip_serializing_if = "QueuedOneShots::is_empty")]
257 included_one_shots: QueuedOneShots,
258 #[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
284pub 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 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 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#[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, ¤t_send_bucket())?;
324 Ok((state, batch))
325 })
326}
327
328pub fn begin_daily_send() -> Result<DailySendLease, String> {
331 begin_send(SendTrigger::Periodic)
332}
333
334pub 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 pending.batch.clone()
354 } else {
355 let (batch, unsent) = freeze_batch(&mut state, &bucket)?;
356 if sent_today && !unsent {
357 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
374fn 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 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
421fn 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 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#[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
517pub(crate) fn current_send_bucket() -> String {
519 send_clock().0
520}
521
522fn 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#[cfg(test)]
538static TEST_CLOCK: Mutex<Option<(String, i64)>> = Mutex::new(None);
539
540#[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
588fn 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
814fn 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
914fn 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
921fn 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
950pub 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, ¤t_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
1082pub fn record_feature(code: &str, ok: bool) -> Result<(), String> {
1085 record_feature_counts(&[(code, 1, u64::from(!ok))])
1086}
1087
1088pub 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
1141fn 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 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
1236fn 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
1254fn today_totals(state: &mut OneShotState) -> &mut QueuedOneShots {
1256 day_totals(state, ¤t_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, ¤t_send_bucket());
1304 Ok(state)
1305}
1306
1307fn before(day: &str) -> (std::ops::Bound<&str>, std::ops::Bound<&str>) {
1309 (std::ops::Bound::Unbounded, std::ops::Bound::Excluded(day))
1310}
1311
1312fn 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
1347fn 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 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, ¤t_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
1436const 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
1443pub(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, ×tamp_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;