diff --git a/ROADMAP.md b/ROADMAP.md index 3071ad1..0276b9c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -474,7 +474,7 @@ alerte). | # | Issue | Effort | |---|---|---| -| [#76](https://git.dracodev.net/Projets/agent-manager/issues/76) | Télémétrie anonyme opt-in (compteurs agrégés uniquement) | M | +| [#76](https://git.dracodev.net/Projets/agent-manager/issues/76) | ✅ Télémétrie anonyme opt-in (compteurs agrégés uniquement) | M | | [#77](https://git.dracodev.net/Projets/agent-manager/issues/77) | Registre communautaire — am registry (publication + recherche sur Gitea) | L | | [#78](https://git.dracodev.net/Projets/agent-manager/issues/78) | am ask — langage naturel → commande am (fournisseur LLM optionnel) | L | | [#79](https://git.dracodev.net/Projets/agent-manager/issues/79) | Profils sandbox par agent (commandes/répertoires autorisés) | L | diff --git a/config.yaml b/config.yaml index 6086f05..d875219 100644 --- a/config.yaml +++ b/config.yaml @@ -43,6 +43,12 @@ settings: # le journal et l'historique (logs/ et backups/ exclus automatiquement). # sync_repo: https://git.dracodev.net/bruno/am-state.git # sync_on_exit: true # pousse automatiquement à la fermeture du REPL (opt-in) + # Télémétrie anonyme opt-in (#76) : compteurs agrégés uniquement (jamais de + # chemins, commandes ni identifiants). Désactivée par défaut — RIEN n'est + # collecté ni envoyé tant que enabled n'est pas explicitement true. + # telemetry: + # enabled: false + # endpoint: https://exemple.tld/v1/ping # optionnel : envoi batch périodique # Registre des providers LLM (#88) : nom -> base_url, modèles, modèle par # défaut. Le provider par défaut est utilisé à l'install/au run quand aucun # n'est donné (issue #90). Les tokens ne vont JAMAIS ici — ils vivent dans diff --git a/src/app.rs b/src/app.rs index db9725d..48816dc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -155,6 +155,8 @@ impl App { if let Err(e) = crate::events::append(&self.events_dir(), event) { self.log.verbose(&format!("cannot write event journal: {e:#}")); } + // Issue #76: aggregated anonymous counters (no-op while disabled). + crate::telemetry::maybe_record(self, event); crate::plugins::dispatch(self, event); } diff --git a/src/config.rs b/src/config.rs index 172a9a4..1a78753 100644 --- a/src/config.rs +++ b/src/config.rs @@ -136,6 +136,10 @@ pub struct Settings { /// Push automatically when the REPL exits (issue #66, opt-in). #[serde(default)] pub sync_on_exit: Option, + /// Anonymous opt-in telemetry (issue #76): aggregated counters only. + /// Nothing is collected or sent while `enabled` is false (the default). + #[serde(default)] + pub telemetry: Option, /// Plugin scripts (issue #75): default timeout and enable list. #[serde(default)] pub plugins: Option, @@ -151,6 +155,18 @@ pub struct Settings { pub providers: Option>>, } +/// Anonymous opt-in telemetry (issue #76): aggregated counters only — never +/// paths, commands, agent names or identifiers. Disabled by default. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct TelemetrySettings { + /// Explicit opt-in: nothing is collected or sent while false. + pub enabled: bool, + /// Batch endpoint (e.g. https://am-telemetry.example/v1/ping). When + /// unset, counters stay local even when enabled. + pub endpoint: Option, +} + /// One entry of the LLM provider registry (issue #88). #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] @@ -853,6 +869,9 @@ pub fn merge(base: &mut Config, overlay: Config) { if o.sync_on_exit.is_some() { s.sync_on_exit = o.sync_on_exit; } + if o.telemetry.is_some() { + s.telemetry = o.telemetry; + } if o.plugins.is_some() { s.plugins = o.plugins; } diff --git a/src/lib.rs b/src/lib.rs index d588d34..eb7f020 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,6 +49,7 @@ pub mod sessions; pub mod shell; pub mod state; pub mod sync; +pub mod telemetry; pub mod playbook; pub mod plugins; pub mod tables; @@ -105,8 +106,13 @@ fn real_main() -> i32 { app.log.verbose(&format!("session retention skipped: {e:#}")); } match commands::execute(&app) { - Ok(code) => code, + Ok(code) => { + // Issue #76: batched anonymous telemetry, fire-and-forget. + crate::telemetry::maybe_flush(&app); + code + } Err(err) => { + crate::telemetry::maybe_flush(&app); if app.json() { let payload = serde_json::json!({ "error": format!("{:#}", err), diff --git a/src/telemetry.rs b/src/telemetry.rs new file mode 100644 index 0000000..48472ee --- /dev/null +++ b/src/telemetry.rs @@ -0,0 +1,344 @@ +//! Anonymous opt-in telemetry (issue #76): aggregated counters only. +//! +//! Nothing is collected or sent while `settings.telemetry.enabled` is false +//! (the default). When enabled, every emitted event increments a counter by +//! kind — never by agent name, path, command or any identifier. The counter +//! file lives next to the state file (`telemetry.json`) and is batched to +//! the configured endpoint with a simple backoff; failures never block or +//! slow down the CLI (fire-and-forget, verbose log only). + +use crate::app::App; +use crate::events::Event; +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Batch thresholds (issue #76): send when at least 10 events accumulated, +/// or when the last attempt is older than 7 days. +const BATCH_MIN_EVENTS: u64 = 10; +const BATCH_MAX_AGE_DAYS: i64 = 7; +/// Give up after 3 consecutive failures until the age threshold passes +/// again (exponential-ish backoff without a timer). +const MAX_CONSECUTIVE_FAILURES: u32 = 3; + +fn version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Counters { + schema: u32, + version: String, + first_seen: String, + last_seen: String, + /// Counters by event kind (aggregated, never per-agent). + events: BTreeMap, + /// Run/start outcomes, aggregated. + runs_succeeded: u64, + runs_failed: u64, + #[serde(default)] + sent_at: Option, + #[serde(default)] + fail_count: u32, +} + +impl Counters { + fn new(version: &str) -> Self { + let now = crate::installers::now_rfc3339(); + Counters { + schema: 1, + version: version.to_string(), + first_seen: now.clone(), + last_seen: now, + events: BTreeMap::new(), + runs_succeeded: 0, + runs_failed: 0, + sent_at: None, + fail_count: 0, + } + } + + fn total(&self) -> u64 { + self.events.values().sum() + } +} + +fn counters_path(app: &App) -> PathBuf { + app.paths + .state_file + .parent() + .unwrap_or(Path::new(".")) + .join("telemetry.json") +} + +fn enabled(app: &App) -> bool { + app.config + .settings + .telemetry + .as_ref() + .map(|t| t.enabled) + .unwrap_or(false) +} + +fn load(path: &Path, version: &str) -> Counters { + match std::fs::read_to_string(path) + .ok() + .and_then(|t| serde_json::from_str::(&t).ok()) + { + Some(mut c) => { + c.last_seen = crate::installers::now_rfc3339(); + c + } + None => Counters::new(version), + } +} + +fn save(path: &Path, counters: &Counters) { + if let Ok(json) = serde_json::to_string_pretty(counters) { + let _ = std::fs::write(path, json); + } +} + +/// Record one event into the aggregated counters. No-op while telemetry is +/// disabled (nothing is even written locally). Only the kind and the +/// run/start outcome are counted — the payload (args, agent, cwd, env keys) +/// is deliberately ignored. +pub fn maybe_record(app: &App, event: &Event) { + if !enabled(app) { + return; + } + let path = counters_path(app); + let mut counters = load(&path, version()); + let kind = event.kind.as_str(); + *counters.events.entry(kind.to_string()).or_insert(0) += 1; + match event.exit_code { + Some(0) => counters.runs_succeeded += 1, + Some(_) => counters.runs_failed += 1, + None => {} + } + save(&path, &counters); +} + +/// Batch flush, called at the end of every CLI run (fire-and-forget): +/// sends the aggregated counters when the thresholds are met, with a simple +/// backoff on failure. Never blocks: errors are verbose-logged only. +pub fn maybe_flush(app: &App) { + let Some(telemetry) = &app.config.settings.telemetry else { + return; + }; + if !telemetry.enabled { + return; + } + let Some(endpoint) = telemetry.endpoint.as_deref().filter(|e| !e.is_empty()) else { + return; // counters stay local + }; + let path = counters_path(app); + let counters = load(&path, version()); + let now = chrono::Utc::now(); + let age_days = counters + .sent_at + .as_deref() + .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()) + .map(|t| (now - t.with_timezone(&chrono::Utc)).num_days()) + .unwrap_or(i64::MAX); + let due = counters.total() >= BATCH_MIN_EVENTS || age_days >= BATCH_MAX_AGE_DAYS; + if !due || counters.fail_count >= MAX_CONSECUTIVE_FAILURES { + return; + } + match send(endpoint, &counters) { + Ok(()) => { + let mut fresh = Counters::new(version()); + fresh.sent_at = Some(crate::installers::now_rfc3339()); + save(&path, &fresh); + app.log.verbose("telemetry batch sent (aggregated counters)"); + } + Err(e) => { + let mut updated = counters; + updated.fail_count += 1; + save(&path, &updated); + app.log + .verbose(&format!("telemetry batch failed (will retry): {e:#}")); + } + } +} + +fn send(endpoint: &str, counters: &Counters) -> Result<()> { + let body = serde_json::to_string(counters)?; + let resp = ureq::post(endpoint) + .set("Content-Type", "application/json") + .set("User-Agent", &format!("agent-manager/{}", counters.version)) + .timeout(std::time::Duration::from_secs(5)) + .send_string(&body) + .map_err(|e| anyhow::anyhow!("{e}"))?; + if !(200..300).contains(&resp.status()) { + anyhow::bail!("HTTP {}", resp.status()); + } + Ok(()) +} + +/// Human-readable status (used by tests and future `am telemetry` output). +pub fn status(app: &App) -> String { + match &app.config.settings.telemetry { + None => "telemetry: off (default) — set settings.telemetry.enabled: true to opt in" + .to_string(), + Some(t) if !t.enabled => "telemetry: off (settings.telemetry.enabled: false)".to_string(), + Some(t) => { + let path = counters_path(app); + let c = load(&path, version()); + let endpoint = t + .endpoint + .as_deref() + .filter(|e| !e.is_empty()) + .unwrap_or("(local only)"); + format!( + "telemetry: on — endpoint {endpoint} · {} events pending · failures {}", + c.total(), + c.fail_count + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::App; + use crate::events::{Event, EventKind}; + use clap::Parser; + + fn test_app(telemetry_yaml: &str) -> App { + let guard = tempfile::tempdir().unwrap(); + let dir = guard.path().to_path_buf(); + std::mem::forget(guard); + let cfg = dir.join("config.yaml"); + std::fs::write( + &cfg, + format!( + "version: \"1.0\"\nsettings:\n auto_install_deps: false\n confirm_before_run: false\n{telemetry_yaml}agents: []\n" + ), + ) + .unwrap(); + let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); + let mut app = crate::app::App::from_cli(cli).unwrap(); + // Isolate the counters file (like dry_run_test): the default state + // dir belongs to the real user. + let mut p = app.paths.clone(); + p.state_file = dir.join("state.json"); + app.paths = p; + app + } + + fn ev(kind: EventKind, code: Option) -> Event { + let mut e = Event::now(kind); + e.exit_code = code; + e + } + + #[test] + fn disabled_telemetry_records_nothing() { + let app = test_app(""); + let path = counters_path(&app); + maybe_record(&app, &ev(EventKind::Run, Some(0))); + maybe_record(&app, &ev(EventKind::Install, None)); + maybe_flush(&app); + assert!(!path.exists(), "nothing must be written while disabled"); + } + + #[test] + fn enabled_records_aggregated_counters_only() { + let app = test_app(" telemetry:\n enabled: true\n"); + maybe_record(&app, &ev(EventKind::Run, Some(0))); + maybe_record(&app, &ev(EventKind::Run, Some(1))); + maybe_record(&app, &ev(EventKind::Install, None)); + let path = counters_path(&app); + let c = load(&path, version()); + assert_eq!(c.events.get("run"), Some(&2)); + assert_eq!(c.events.get("install"), Some(&1)); + assert_eq!(c.runs_succeeded, 1); + assert_eq!(c.runs_failed, 1); + assert_eq!(c.total(), 3); + // The aggregated payload carries no identifiers. + let json = serde_json::to_string(&c).unwrap(); + assert!(!json.contains("claude"), "{json}"); + assert!(!json.contains("C:"), "no paths: {json}"); + } + + #[test] + fn flush_without_endpoint_keeps_counters_local() { + let app = test_app(" telemetry:\n enabled: true\n"); + for _ in 0..12 { + maybe_record(&app, &ev(EventKind::Run, Some(0))); + } + maybe_flush(&app); + let c = load(&counters_path(&app), version()); + assert_eq!(c.total(), 12, "no endpoint → counters stay local"); + assert_eq!(c.fail_count, 0); + } + + #[test] + fn flush_sends_the_batch_and_resets() { + // A local tiny_http server receives the batch (issue #76 test of the + // batcher end to end without any external dependency). The server + // runs on its own thread: it must answer while maybe_flush blocks. + let server = tiny_http::Server::http("127.0.0.1:0").unwrap(); + let port = server.server_addr().to_ip().unwrap().port(); + let app = test_app(&format!( + " telemetry:\n enabled: true\n endpoint: http://127.0.0.1:{port}/v1/ping\n" + )); + for _ in 0..BATCH_MIN_EVENTS { + maybe_record(&app, &ev(EventKind::Run, Some(0))); + } + let handle = std::thread::spawn(move || { + let mut req = server + .recv_timeout(std::time::Duration::from_secs(10)) + .expect("server recv failed") + .expect("the batcher must POST the aggregated counters"); + let mut body = String::new(); + req.as_reader().read_to_string(&mut body).unwrap(); + let method = req.method().as_str().to_string(); + let url = req.url().to_string(); + let _ = req.respond(tiny_http::Response::from_string("ok")); + (method, url, body) + }); + maybe_flush(&app); // blocks until the server responds + let (method, url, body) = handle.join().expect("server thread"); + assert_eq!(method, "POST"); + assert_eq!(url, "/v1/ping"); + let parsed: Counters = serde_json::from_str(&body).unwrap(); + assert_eq!(parsed.events.get("run"), Some(&BATCH_MIN_EVENTS)); + assert!(parsed.sent_at.is_none(), "sent_at is server-side state"); + // After a successful send, the counters are reset. + let c = load(&counters_path(&app), version()); + assert_eq!(c.total(), 0); + assert_eq!(c.fail_count, 0); + assert!(c.sent_at.is_some(), "sent_at recorded after success"); + } + + #[test] + fn flush_failure_increments_the_backoff_counter() { + // 127.0.0.1:1 refuses connections immediately. + let app = test_app( + " telemetry:\n enabled: true\n endpoint: http://127.0.0.1:1/v1/ping\n", + ); + for _ in 0..BATCH_MIN_EVENTS { + maybe_record(&app, &ev(EventKind::Run, Some(0))); + } + maybe_flush(&app); + let c = load(&counters_path(&app), version()); + assert_eq!(c.fail_count, 1); + assert_eq!(c.total(), BATCH_MIN_EVENTS, "counters survive a failure"); + // Backoff: after MAX_CONSECUTIVE_FAILURES the batcher stops trying + // until the age threshold passes again. + for _ in 0..(MAX_CONSECUTIVE_FAILURES) { + maybe_flush(&app); + } + let c = load(&counters_path(&app), version()); + assert_eq!(c.fail_count, MAX_CONSECUTIVE_FAILURES); + maybe_flush(&app); // now suppressed + let c = load(&counters_path(&app), version()); + assert_eq!(c.fail_count, MAX_CONSECUTIVE_FAILURES, "backoff holds"); + } + + use std::io::Read; +}