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:
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user