use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
const SYNC_INTERVAL_HOURS: i64 = 6;
const SNAPSHOT_WINDOW_DAYS: i64 = 35;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TelemetryState {
pub device_id: String,
pub user_id: String,
pub epoch: String,
pub last_attempt: Option<String>,
pub last_success: Option<String>,
}
fn bctx_dir() -> PathBuf {
PathBuf::from(crate::commands::home_dir()).join(".bctx")
}
fn state_path() -> PathBuf {
bctx_dir().join("telemetry.json")
}
fn write_atomically(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4().simple()));
std::fs::write(&tmp, contents)?;
std::fs::rename(&tmp, path).inspect_err(|_| {
let _ = std::fs::remove_file(&tmp);
})
}
fn save_state(state: &TelemetryState) -> Result<()> {
std::fs::create_dir_all(bctx_dir())?;
write_atomically(
&state_path(),
serde_json::to_string_pretty(state)?.as_bytes(),
)?;
Ok(())
}
pub fn load_or_init_state(user_id: &str) -> Result<TelemetryState> {
if let Ok(raw) = std::fs::read_to_string(state_path()) {
if let Ok(state) = serde_json::from_str::<TelemetryState>(&raw) {
if state.user_id == user_id {
return Ok(state);
}
}
}
let state = TelemetryState {
device_id: uuid::Uuid::new_v4().to_string(),
user_id: user_id.to_string(),
epoch: Utc::now().to_rfc3339(),
last_attempt: None,
last_success: None,
};
save_state(&state)?;
Ok(state)
}
pub fn sync_due(state: &TelemetryState, now: DateTime<Utc>) -> bool {
match state
.last_attempt
.as_deref()
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
{
Some(t) => {
let t = t.with_timezone(&Utc);
t > now || now - t >= Duration::hours(SYNC_INTERVAL_HOURS)
}
None => true,
}
}
pub fn snapshot_since(state: &TelemetryState, now: DateTime<Utc>) -> String {
let window = (now - Duration::days(SNAPSHOT_WINDOW_DAYS - 1))
.date_naive()
.and_hms_opt(0, 0, 0)
.expect("midnight exists")
.and_utc();
match DateTime::parse_from_rfc3339(&state.epoch) {
Ok(epoch) if epoch.with_timezone(&Utc) > window => epoch.with_timezone(&Utc).to_rfc3339(),
_ => window.to_rfc3339(),
}
}
fn snapshot_rows(daily: Vec<forge::tracker::store::DailyUsage>) -> Vec<cloud::usage::UsageRow> {
let mut sums: std::collections::BTreeMap<(String, String), (i64, i64, i64)> =
std::collections::BTreeMap::new();
for d in daily {
let e = sums
.entry((d.day, cloud::usage::skill_name(&d.program)))
.or_default();
e.0 += d.tokens_sent;
e.1 += d.tokens_saved;
e.2 += d.runs;
}
sums.into_iter()
.map(
|((day, skill), (tokens_sent, tokens_saved, runs))| cloud::usage::UsageRow {
day,
skill,
tokens_sent,
tokens_saved,
runs,
},
)
.collect()
}
pub fn forget_device() -> Result<()> {
match std::fs::remove_file(state_path()) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
_ => Ok(()),
}
}
pub fn start_device_before_recording() {
if let Some(token) = cloud::client::auth::load_token() {
let _ = load_or_init_state(&token.user_id);
}
}
pub fn maybe_spawn_sync() {
let Some(token) = cloud::client::auth::load_token() else {
return;
};
let Ok(mut state) = load_or_init_state(&token.user_id) else {
return;
};
let now = Utc::now();
if !sync_due(&state, now) {
return;
}
state.last_attempt = Some(now.to_rfc3339());
if save_state(&state).is_err() {
return;
}
if let Ok(exe) = std::env::current_exe() {
let _ = crate::detach::spawn(|| {
let mut cmd = std::process::Command::new(&exe);
cmd.args(["sync", "--telemetry"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
cmd
});
}
}
pub fn sync_now() -> Result<usize> {
let token = cloud::client::auth::load_token().context("not logged in (run `bctx login`)")?;
let mut state = load_or_init_state(&token.user_id)?;
let now = Utc::now();
let store = forge::tracker::store::ExecutionStore::open(&bctx_dir().join("executions.db"))?;
let mut rows = snapshot_rows(store.daily_rollup(&snapshot_since(&state, now))?);
if rows.len() > cloud::usage::MAX_SNAPSHOT_ROWS {
rows.sort_by(|a, b| b.day.cmp(&a.day));
rows.truncate(cloud::usage::MAX_SNAPSHOT_ROWS);
}
let sent = rows.len();
let snapshot = cloud::usage::UsageSnapshot {
device_id: state.device_id.clone(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
rows,
};
cloud::client::sync::push_usage_snapshot(&token.endpoint, &token.access_token, &snapshot)?;
state.last_success = Some(now.to_rfc3339());
save_state(&state)?;
Ok(sent)
}
#[cfg(test)]
mod tests {
use super::*;
fn state(epoch: &str, last_attempt: Option<&str>) -> TelemetryState {
TelemetryState {
device_id: "d".into(),
user_id: "u".into(),
epoch: epoch.into(),
last_attempt: last_attempt.map(String::from),
last_success: None,
}
}
fn at(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
}
#[test]
fn sync_is_due_without_attempts_and_after_the_interval() {
let now = at("2026-10-18T12:00:00+00:00");
assert!(sync_due(&state("2026-10-01T00:00:00+00:00", None), now));
assert!(!sync_due(
&state(
"2026-10-01T00:00:00+00:00",
Some("2026-10-18T07:00:00+00:00")
),
now
));
assert!(sync_due(
&state(
"2026-10-01T00:00:00+00:00",
Some("2026-10-18T06:00:00+00:00")
),
now
));
assert!(
sync_due(
&state(
"2026-10-01T00:00:00+00:00",
Some("2026-10-18T13:00:00+00:00")
),
now
),
"an attempt stamped in the future is due"
);
}
#[test]
fn snapshot_never_reaches_before_the_epoch_or_the_window() {
let now = at("2026-10-18T12:00:00+00:00");
assert_eq!(
snapshot_since(&state("2026-10-17T09:30:00+00:00", None), now),
"2026-10-17T09:30:00+00:00"
);
assert_eq!(
snapshot_since(&state("2026-01-01T00:00:00+00:00", None), now),
"2026-09-14T00:00:00+00:00"
);
}
#[test]
fn the_oldest_day_is_always_sent_whole() {
let old = state("2026-01-01T00:00:00+00:00", None);
let morning = snapshot_since(&old, at("2026-10-18T09:00:00+00:00"));
let evening = snapshot_since(&old, at("2026-10-18T15:00:00+00:00"));
assert_eq!(morning, evening);
assert!(morning.ends_with("T00:00:00+00:00"), "{morning}");
}
fn usage(day: &str, program: &str, sent: i64, saved: i64) -> forge::tracker::store::DailyUsage {
forge::tracker::store::DailyUsage {
day: day.into(),
program: program.into(),
tokens_sent: sent,
tokens_saved: saved,
runs: 1,
}
}
#[test]
fn only_the_program_name_leaves_the_machine() {
let rows = snapshot_rows(vec![
usage(
"2026-10-18",
"cargo test --workspace --locked 2>&1 | grep -E 'test result'",
10,
90,
),
usage("2026-10-18", "/usr/bin/git", 5, 5),
usage("2026-10-18", "ls;<img src=x onerror=alert(1)>", 1, 1),
usage(
"2026-10-18",
"export TOKEN=secret; curl https://example.com",
2,
2,
),
]);
let names: Vec<&str> = rows.iter().map(|r| r.skill.as_str()).collect();
assert_eq!(names, vec!["cargo", "export", "git", "ls"]);
}
#[test]
fn rows_that_end_up_with_the_same_name_are_added_together() {
let rows = snapshot_rows(vec![
usage("2026-10-18", "git", 10, 40),
usage("2026-10-18", "git status | head", 5, 5),
]);
assert_eq!(rows.len(), 1);
assert_eq!(
(rows[0].tokens_sent, rows[0].tokens_saved, rows[0].runs),
(15, 45, 2)
);
}
#[test]
fn a_reader_never_sees_a_half_written_state_file() {
let dir = std::env::temp_dir().join(format!("bctx-atomic-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("telemetry.json");
let doc = |n: usize| serde_json::json!({ "n": n, "pad": "x".repeat(200_000) }).to_string();
write_atomically(&path, doc(0).as_bytes()).unwrap();
let writer = {
let path = path.clone();
std::thread::spawn(move || {
for n in 1..=300 {
write_atomically(&path, doc(n).as_bytes()).unwrap();
}
})
};
let mut torn = 0;
while !writer.is_finished() {
let raw = std::fs::read_to_string(&path).unwrap_or_default();
if serde_json::from_str::<serde_json::Value>(&raw).is_err() {
torn += 1;
}
}
writer.join().unwrap();
let leftovers = std::fs::read_dir(&dir).unwrap().count();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(torn, 0, "{torn} reads saw a half-written file");
assert_eq!(leftovers, 1, "temporary files were left behind");
}
#[test]
fn a_name_is_never_longer_than_the_server_accepts() {
let long = "a".repeat(100);
let rows = snapshot_rows(vec![
usage("2026-10-18", &long, 1, 1),
usage("2026-10-18", ";;", 1, 1),
]);
assert!(rows
.iter()
.all(|r| !r.skill.is_empty() && r.skill.len() <= 64));
}
}