use serde_json::{json, Map, Value};
#[derive(Debug, Clone)]
pub struct Event {
pub name: String,
pub properties: Map<String, Value>,
}
impl Event {
fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
properties: Map::new(),
}
}
fn with(mut self, key: &str, value: Value) -> Self {
self.properties.insert(key.to_string(), value);
self
}
pub fn cli_initialized(
agent_detected: &str,
hooks_installed_count: usize,
first_run: bool,
) -> Self {
Self::new("cli_initialized")
.with("agent_detected", json!(agent_detected))
.with("hooks_installed_count", json!(hooks_installed_count))
.with("first_run", json!(first_run))
}
pub fn auth_completed(auth_method: &str, duration_ms: u64) -> Self {
Self::new("auth_completed")
.with("auth_method", json!(auth_method))
.with("duration_ms", json!(duration_ms))
}
pub fn auth_failed(error_code: &str, stage: &str) -> Self {
Self::new("auth_failed")
.with("error_code", json!(error_code))
.with("stage", json!(stage))
}
pub fn uninstalled(agents_removed_count: usize) -> Self {
Self::new("uninstalled").with("agents_removed_count", json!(agents_removed_count))
}
pub fn supervision_installed(backend: &str, mode: &str, deferred_reason: Option<&str>) -> Self {
let mut e = Self::new("supervision_installed")
.with("backend", json!(backend))
.with("mode", json!(mode));
if let Some(r) = deferred_reason {
e = e.with("deferred_reason", json!(r));
}
e
}
pub fn hook_source_unknown(source: &str) -> Self {
Self::new("hook_source_unknown").with("source", json!(source))
}
pub fn hook_type_unknown(type_str: &str) -> Self {
Self::new("hook_type_unknown").with("type", json!(type_str))
}
pub fn daemon_started(port: u16, startup_ms: u64, cloud_enabled: bool) -> Self {
Self::new("daemon_started")
.with("port", json!(port))
.with("startup_ms", json!(startup_ms))
.with("cloud_enabled", json!(cloud_enabled))
}
pub fn daemon_stopped(uptime_seconds: u64, events_processed_total: u64) -> Self {
Self::new("daemon_stopped")
.with("uptime_seconds", json!(uptime_seconds))
.with("events_processed_total", json!(events_processed_total))
}
pub fn daemon_crashed(panic_location: &str, uptime_seconds: u64) -> Self {
Self::new("daemon_crashed")
.with("panic_location", json!(panic_location))
.with("uptime_seconds", json!(uptime_seconds))
}
pub fn command_invoked(
command: &str,
subcommand: Option<&str>,
exit_code: i32,
duration_ms: u64,
) -> Self {
let mut e = Self::new("command_invoked")
.with("command", json!(command))
.with("exit_code", json!(exit_code))
.with("duration_ms", json!(duration_ms));
if let Some(s) = subcommand {
e = e.with("subcommand", json!(s));
}
e
}
pub fn hooks_processed(rollup: Value, window_seconds: u64) -> Self {
Self::new("hooks_processed")
.with("rollup", rollup)
.with("window_seconds", json!(window_seconds))
}
pub fn cloud_error(
error_code: &str,
http_status: Option<u16>,
retry_count: u32,
latency_ms: u64,
) -> Self {
let mut e = Self::new("cloud_error")
.with("error_code", json!(error_code))
.with("retry_count", json!(retry_count))
.with("latency_ms", json!(latency_ms));
if let Some(s) = http_status {
e = e.with("http_status", json!(s));
}
e
}
pub fn tamper_detected(detection_method: &str, agent_type: &str) -> Self {
Self::new("tamper_detected")
.with("detection_method", json!(detection_method))
.with("agent_type", json!(agent_type))
}
pub fn tamper_healed(
detection_method: &str,
agent_type: &str,
outcome: &str,
attempt: u32,
) -> Self {
Self::new("tamper_healed")
.with("detection_method", json!(detection_method))
.with("agent_type", json!(agent_type))
.with("outcome", json!(outcome))
.with("attempt", json!(attempt))
}
#[allow(clippy::too_many_arguments)] pub fn doctor_fix_run(
categories_selected: Vec<&str>,
checks_before_pass: usize,
checks_before_fail: usize,
checks_after_pass: usize,
checks_after_fail: usize,
unfixable_codes: Vec<&str>,
duration_ms: u64,
auto_rollback_triggered: bool,
) -> Self {
Self::new("doctor_fix_run")
.with("categories_selected", json!(categories_selected))
.with(
"checks_before",
json!({ "pass": checks_before_pass, "fail": checks_before_fail }),
)
.with(
"checks_after",
json!({ "pass": checks_after_pass, "fail": checks_after_fail }),
)
.with("unfixable_codes", json!(unfixable_codes))
.with("duration_ms", json!(duration_ms))
.with("auto_rollback_triggered", json!(auto_rollback_triggered))
}
pub fn doctor_restore_run(
actions_reversed: usize,
actions_skipped: usize,
daemon_restart_required: bool,
duration_ms: u64,
) -> Self {
Self::new("doctor_restore_run")
.with("actions_reversed", json!(actions_reversed))
.with("actions_skipped", json!(actions_skipped))
.with("daemon_restart_required", json!(daemon_restart_required))
.with("duration_ms", json!(duration_ms))
}
#[allow(clippy::too_many_arguments)] pub fn doctor_rescue_run(
archive_size_bytes: u64,
files_collected_count: usize,
daemon_reachable: bool,
agents_found: Vec<&str>,
redactor_hits_total: u64,
duration_ms: u64,
fix_applied_after: bool,
) -> Self {
Self::new("doctor_rescue_run")
.with("archive_size_bytes", json!(archive_size_bytes))
.with("files_collected_count", json!(files_collected_count))
.with("daemon_reachable", json!(daemon_reachable))
.with("agents_found", json!(agents_found))
.with("redactor_hits_total", json!(redactor_hits_total))
.with("duration_ms", json!(duration_ms))
.with("fix_applied_after", json!(fix_applied_after))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cli_initialized_has_required_props() {
let e = Event::cli_initialized("claude-code", 3, true);
assert_eq!(e.name, "cli_initialized");
assert_eq!(e.properties["agent_detected"], "claude-code");
assert_eq!(e.properties["hooks_installed_count"], 3);
assert_eq!(e.properties["first_run"], true);
}
#[test]
fn test_command_invoked_omits_subcommand_when_none() {
let e = Event::command_invoked("status", None, 0, 12);
assert!(!e.properties.contains_key("subcommand"));
}
#[test]
fn test_command_invoked_includes_subcommand_when_some() {
let e = Event::command_invoked("auth", Some("login"), 0, 42);
assert_eq!(e.properties["subcommand"], "login");
}
#[test]
fn test_doctor_fix_run_includes_aggregate_counts_only() {
let e = Event::doctor_fix_run(vec!["state", "hooks"], 3, 3, 6, 0, vec![], 1234, false);
assert_eq!(e.name, "doctor_fix_run");
assert_eq!(e.properties["checks_before"]["pass"], 3);
assert_eq!(e.properties["checks_after"]["fail"], 0);
assert_eq!(e.properties["auto_rollback_triggered"], false);
assert_eq!(e.properties["duration_ms"], 1234);
}
#[test]
fn test_doctor_rescue_run_carries_aggregate_redactor_hits_only() {
let e = Event::doctor_rescue_run(1_234_567, 12, true, vec!["claude-code"], 42, 3000, false);
assert_eq!(e.name, "doctor_rescue_run");
assert_eq!(e.properties["archive_size_bytes"], 1_234_567);
assert_eq!(e.properties["redactor_hits_total"], 42);
assert!(!e.properties.contains_key("redactor_hits_by_pattern"));
}
#[test]
fn test_supervision_installed_omits_reason_when_active() {
let e = Event::supervision_installed("launchd", "active", None);
assert_eq!(e.name, "supervision_installed");
assert_eq!(e.properties["backend"], "launchd");
assert_eq!(e.properties["mode"], "active");
assert!(!e.properties.contains_key("deferred_reason"));
}
#[test]
fn test_supervision_installed_includes_reason_when_deferred() {
let e = Event::supervision_installed("task_scheduler", "deferred", Some("user_opt_out"));
assert_eq!(e.properties["mode"], "deferred");
assert_eq!(e.properties["deferred_reason"], "user_opt_out");
}
#[test]
fn test_daemon_crashed_accepts_file_line_only() {
let e = Event::daemon_crashed("src/daemon/mod.rs:214", 3600);
let loc = e.properties["panic_location"].as_str().unwrap();
assert!(loc.contains(':'));
assert!(!loc.contains("panic at"));
}
#[test]
fn test_tamper_detected_carries_aggregates_only() {
let e = Event::tamper_detected("hmac_mismatch", "claude-code");
assert_eq!(e.name, "tamper_detected");
assert_eq!(e.properties["detection_method"], "hmac_mismatch");
assert_eq!(e.properties["agent_type"], "claude-code");
assert!(!e.properties.contains_key("entry_id"));
assert!(!e.properties.contains_key("settings_path_hash"));
assert!(!e.properties.contains_key("field_deltas"));
}
#[test]
fn test_tamper_healed_includes_outcome_and_attempt() {
let e = Event::tamper_healed("hmac_mismatch", "claude-code", "succeeded", 2);
assert_eq!(e.name, "tamper_healed");
assert_eq!(e.properties["outcome"], "succeeded");
assert_eq!(e.properties["attempt"], 2);
assert_eq!(e.properties["detection_method"], "hmac_mismatch");
}
}