events : instrumentation start/stop/run/install/update/uninstall/doctor/repl/shell (issue #3) - un evenement JSONL par action reelle, dry-run sans evenement, session id sur start, duree et exit code sur stop/run

This commit is contained in:
2026-08-16 22:13:49 -04:00
parent ba901df953
commit 310da96a63
10 changed files with 297 additions and 37 deletions
+17
View File
@@ -121,6 +121,23 @@ impl App {
self.theme.set(theme);
}
/// Directory of the event journal (the state directory).
pub fn events_dir(&self) -> PathBuf {
self.paths
.state_file
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| self.paths.state_file.clone())
}
/// Append an event to the journal; failures are never fatal (logged at
/// verbose level only).
pub fn emit(&self, event: &crate::events::Event) {
if let Err(e) = crate::events::append(&self.events_dir(), event) {
self.log.verbose(&format!("cannot write event journal: {e:#}"));
}
}
/// Ask the user for confirmation on stderr. Accepts y/yes/o/oui.
pub fn confirm(&self, question: &str) -> Result<bool> {
if self.cli.yes {
+7
View File
@@ -2,6 +2,7 @@
use super::*;
use crate::doctor::{self, Status};
use crate::events::{Event, EventKind};
use crate::output::{boxed_table, print_json, status_style};
use crate::runner::SystemRunner;
use anyhow::Result;
@@ -22,6 +23,12 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
// The doctor report probes real tools, even under --dry-run.
let runner = SystemRunner::new(false, false, &app.log);
let report = doctor::run(app, &runner);
if !app.dry_run() {
app.emit(
&Event::now(EventKind::Doctor)
.with_exit_code(if report.ok() { 0 } else { 1 }),
);
}
if app.json() {
print_json(&report.checks);
return Ok(if report.ok() { 0 } else { 1 });
+6
View File
@@ -2,6 +2,7 @@
//! installer, and record the installation in the state database.
use super::*;
use crate::events::{Event, EventKind};
use crate::installers;
use crate::runner::SystemRunner;
use anyhow::{anyhow, bail, Result};
@@ -137,6 +138,11 @@ pub fn run(app: &App, agent_name: &str, method_sel: Option<&str>, force: bool) -
let entry = installers::make_entry(agent, method, &outcome, app);
let version = entry.version.clone().unwrap_or_else(|| "unknown".to_string());
app.state.set(&entry)?;
app.emit(
&Event::now(EventKind::Install)
.with_agent(agent.name.clone())
.with_args(vec![format!("method={}", method.kind)]),
);
app.log.success(&format!(
"installed {} (v{version}) in {}",
agent.title(),
+65 -2
View File
@@ -2,6 +2,7 @@
use super::*;
use crate::cli::StartArgs;
use crate::events::{self, Event, EventKind};
use crate::process;
use anyhow::{bail, Context, Result};
use std::collections::BTreeMap;
@@ -51,6 +52,15 @@ fn start_one(
agent.name
));
}
app.emit(
&Event::now(EventKind::Start)
.with_agent(agent.name.clone())
.with_pid(pid)
.with_session(crate::repl::session_id())
.with_cwd(cwd_string())
.with_args(exec.args.clone())
.with_env_keys(events::env_keys(&exec.env)),
);
let log = process::agent_log_path(app, &agent.name);
app.log.success(&format!(
"{} started (pid {pid}); log: {}",
@@ -70,6 +80,7 @@ fn start_one(
));
return Ok(0);
}
let started = std::time::Instant::now();
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
@@ -78,7 +89,17 @@ fn start_one(
.envs(&exec.env)
.status()
.with_context(|| format!("failed to run {}", exec.program))?;
Ok(status.code().unwrap_or(1))
let code = status.code().unwrap_or(1);
app.emit(
&Event::now(EventKind::Run)
.with_agent(agent.name.clone())
.with_cwd(cwd_string())
.with_args(exec.args.clone())
.with_env_keys(events::env_keys(&exec.env))
.with_exit_code(code)
.with_duration(started.elapsed().as_secs()),
);
Ok(code)
}
}
@@ -123,11 +144,27 @@ fn stop_one(app: &App, agent: &AgentDef, force: bool, timeout: u64) -> Result<i3
"stale PID {pid} for '{}' (process no longer running); cleaning up",
agent.name
));
app.emit(
&Event::now(EventKind::Stop)
.with_agent(agent.name.clone())
.with_pid(pid)
.with_reason("stale"),
);
app.state.update_pid(&agent.name, None, None)?;
return Ok(0);
}
app.log.info(&format!("stopping '{}' (pid {pid})", agent.name));
let stopped = process::stop_pid(pid, force, timeout);
let duration = seconds_since(entry.started_at.as_deref());
let mut ev = Event::now(EventKind::Stop)
.with_agent(agent.name.clone())
.with_pid(pid)
.with_reason(if force { "force" } else { "signal" })
.with_exit_code(if stopped { 0 } else { 1 });
if let Some(d) = duration {
ev = ev.with_duration(d);
}
app.emit(&ev);
app.state.update_pid(&agent.name, None, None)?;
if stopped {
app.log.success(&format!("{} stopped", agent.title()));
@@ -175,6 +212,7 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
));
return Ok(0);
}
let started = std::time::Instant::now();
let (prog, prefix) = crate::runner::resolve_program(&exec.program);
let mut full_args = prefix;
full_args.extend(exec.args.iter().cloned());
@@ -183,5 +221,30 @@ pub fn run(app: &App, target: &str, extra: &[OsString]) -> Result<i32> {
.envs(&exec.env)
.status()
.with_context(|| format!("failed to run {}", exec.program))?;
Ok(status.code().unwrap_or(1))
let code = status.code().unwrap_or(1);
app.emit(
&Event::now(EventKind::Run)
.with_agent(agent.name.clone())
.with_cwd(cwd_string())
.with_args(exec.args.clone())
.with_env_keys(events::env_keys(&exec.env))
.with_exit_code(code)
.with_duration(started.elapsed().as_secs()),
);
Ok(code)
}
/// Current directory as a string (None when unavailable).
fn cwd_string() -> Option<String> {
std::env::current_dir().ok().map(|p| p.display().to_string())
}
/// Seconds elapsed since an RFC 3339 timestamp (None when unparsable).
fn seconds_since(ts: Option<&str>) -> Option<u64> {
let t = chrono::DateTime::parse_from_rfc3339(ts?).ok()?;
let now = chrono::Utc::now();
let secs = now
.signed_duration_since(t.with_timezone(&chrono::Utc))
.num_seconds();
Some(secs.max(0) as u64)
}
+7
View File
@@ -5,6 +5,7 @@
//! executables found on PATH otherwise.
use super::*;
use crate::events::{Event, EventKind};
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
@@ -57,6 +58,7 @@ pub fn run(app: &App, agent_name: &str, purge: bool) -> Result<i32> {
remove_from_user_config(app, &agent.name)?;
}
app.emit(&Event::now(EventKind::Uninstall).with_agent(agent.name.clone()));
app.log.success(&format!(
"uninstalled {} (files removed{})",
agent.title(),
@@ -178,6 +180,11 @@ fn uninstall_external(app: &App, agent: &AgentDef) -> Result<i32> {
.and_then(|t| which::which(t).ok())
.is_none();
if gone {
app.emit(
&Event::now(EventKind::Uninstall)
.with_agent(agent.name.clone())
.with_reason("external"),
);
app.log
.success(&format!("uninstalled {} (external)", agent.title()));
} else {
+6
View File
@@ -2,6 +2,7 @@
//! reinstall when an update exists.
use super::*;
use crate::events::{Event, EventKind};
use crate::installers;
use crate::runner::{Runner, SystemRunner};
use anyhow::{bail, Result};
@@ -96,6 +97,11 @@ fn update_one(app: &App, runner: &dyn Runner, name: &str) -> Result<i32> {
new_entry.updated_at = Some(installers::now_rfc3339());
let version = new_entry.version.clone().unwrap_or_else(|| "unknown".to_string());
app.state.set(&new_entry)?;
app.emit(
&Event::now(EventKind::Update)
.with_agent(agent.name.clone())
.with_args(vec![format!("method={}", method.kind)]),
);
app.log.success(&format!(
"updated {} to v{version}",
agent.title()
+44 -34
View File
@@ -90,8 +90,8 @@ impl Event {
self
}
pub fn with_cwd(mut self, cwd: impl Into<String>) -> Self {
self.cwd = Some(cwd.into());
pub fn with_cwd(mut self, cwd: Option<String>) -> Self {
self.cwd = cwd;
self
}
@@ -209,6 +209,46 @@ pub fn read_events(dir: &Path, limit: usize) -> Vec<Event> {
out
}
/// Append one event as a single JSON line to the journal file for the
/// event's month (created under `dir`). Returns the file written to.
/// Retries briefly when the file is locked (Windows).
pub fn append(dir: &Path, event: &Event) -> anyhow::Result<PathBuf> {
let path = dir.join(file_name_for_month(&month_of(&event.ts)));
append_to(&path, event)?;
Ok(path)
}
/// Append one event to a specific journal file.
pub fn append_to(path: &Path, event: &Event) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let line = serde_json::to_string(event)?;
let mut last_err: Option<std::io::Error> = None;
for attempt in 0..5 {
match std::fs::OpenOptions::new().create(true).append(true).open(path) {
Ok(mut f) => {
f.write_all(line.as_bytes())?;
f.write_all(b"\n")?;
return Ok(());
}
Err(e) => {
last_err = Some(e);
if attempt < 4 {
std::thread::sleep(std::time::Duration::from_millis(20 * (attempt + 1)));
}
}
}
}
Err(anyhow::anyhow!(
"cannot append to journal {}: {}",
path.display(),
last_err
.map(|e| e.to_string())
.unwrap_or_else(|| "unknown error".to_string())
))
}
/// Append-only journal living in a directory (the state directory).
pub struct EventLog {
dir: PathBuf,
@@ -247,41 +287,11 @@ impl EventLog {
}
/// Append one event as a single JSON line; returns the file written to.
/// Retries briefly when the file is locked (Windows).
pub fn append(&mut self, event: &Event) -> anyhow::Result<PathBuf> {
let month = month_of(&event.ts);
let path = self.file_for(&month);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let line = serde_json::to_string(event)?;
let mut last_err: Option<std::io::Error> = None;
for attempt in 0..5 {
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
Ok(mut f) => {
f.write_all(line.as_bytes())?;
f.write_all(b"\n")?;
return Ok(path);
}
Err(e) => {
last_err = Some(e);
if attempt < 4 {
std::thread::sleep(std::time::Duration::from_millis(20 * (attempt + 1)));
}
}
}
}
Err(anyhow::anyhow!(
"cannot append to journal {}: {}",
path.display(),
last_err
.map(|e| e.to_string())
.unwrap_or_else(|| "unknown error".to_string())
))
append_to(&path, event)?;
Ok(path)
}
}
+14
View File
@@ -15,6 +15,7 @@
use crate::app::App;
use crate::cli::{Command, StartArgs};
use crate::commands;
use crate::events::{Event, EventKind};
use crate::nav;
use crate::ps;
use crate::tables::DataTable;
@@ -884,6 +885,11 @@ pub fn run(app: &App) -> Result<i32> {
}
let mut session = ShellSession::start(&app.config);
let sid = session_id();
app.emit(
&Event::now(EventKind::Repl)
.with_session(sid.clone())
.with_cwd(Some(current_dir_string())),
);
print!("{}", banner(app, &session, &sid));
match run_with_editor(app, &mut session) {
Ok(code) => Ok(code),
@@ -1379,11 +1385,19 @@ fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<
/// Run one system command through the active shell (inherited stdio).
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
app.log.cmd(&format!("{}: {line}", session.current.name));
let started = std::time::Instant::now();
let code = session.run(line)?;
if code != 0 {
app.log
.error(&format!("command exited with code {code}"));
}
app.emit(
&Event::now(EventKind::Shell)
.with_args(vec![line.to_string()])
.with_exit_code(code)
.with_duration(started.elapsed().as_secs())
.with_cwd(Some(current_dir_string())),
);
Ok(())
}