feat: v1.0 — télémétrie anonyme opt-in : compteurs agrégés uniquement, settings.telemetry (désactivé par défaut), batch périodique avec backoff, fire-and-forget, zéro PII (closes #76)

- src/telemetry.rs : compteurs par kind + succès/échec des runs, fichier telemetry.json à côté du state, envoi au seuil (10 events ou 7 jours), backoff 3 échecs consécutifs, reset après succès
- app.emit → maybe_record (no-op si désactivé, rien n'est même écrit localement) ; real_main → maybe_flush
- config.yaml : bloc telemetry documenté (enabled + endpoint optionnel)
- 5 tests : disabled (rien), compteurs agrégés (zéro identifiant dans le JSON), local-only sans endpoint, envoi+reset (serveur tiny_http sur thread dédié), backoff
This commit is contained in:
2026-08-19 21:28:26 -04:00
parent 6d51ce9504
commit 7cab1a0b5c
6 changed files with 379 additions and 2 deletions
+1 -1
View File
@@ -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 |
+6
View File
@@ -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
+2
View File
@@ -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);
}
+19
View File
@@ -136,6 +136,10 @@ pub struct Settings {
/// Push automatically when the REPL exits (issue #66, opt-in).
#[serde(default)]
pub sync_on_exit: Option<bool>,
/// 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<TelemetrySettings>,
/// Plugin scripts (issue #75): default timeout and enable list.
#[serde(default)]
pub plugins: Option<PluginSettings>,
@@ -151,6 +155,18 @@ pub struct Settings {
pub providers: Option<BTreeMap<String, Option<ProviderDef>>>,
}
/// 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<String>,
}
/// 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;
}
+7 -1
View File
@@ -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),
+344
View File
@@ -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<String, u64>,
/// Run/start outcomes, aggregated.
runs_succeeded: u64,
runs_failed: u64,
#[serde(default)]
sent_at: Option<String>,
#[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::<Counters>(&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<i32>) -> 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;
}