diff --git a/crates/smooth-cli/src/main.rs b/crates/smooth-cli/src/main.rs index bcbbe9d6..5693a94d 100644 --- a/crates/smooth-cli/src/main.rs +++ b/crates/smooth-cli/src/main.rs @@ -149,6 +149,14 @@ enum Commands { #[command(subcommand)] cmd: OrgsCommands, }, + /// Smoo AI Observability — error tracking (`errors list/show/ok/not-ok`) + /// and source maps. Top-level alias for `th api observability`, promoted + /// for discoverability alongside `th orgs` / `th config`. + #[command(visible_alias = "o11y")] + Observability { + #[command(subcommand)] + cmd: smooai::observability::Cmd, + }, /// Smoo AI `@smooai/config` — the daily-developer config surface. /// `get` / `set` / `list` for single values; `feature-flag` to /// evaluate a flag; `push` / `pull` / `diff` to sync the @@ -1567,6 +1575,7 @@ async fn main() -> Result<()> { ApiCommands::Observability { cmd } => smooai::observability::cmd(cmd).await, }, Some(Commands::Org { cmd }) => cmd_orgs(cmd).await, + Some(Commands::Observability { cmd }) => smooai::observability::cmd(cmd).await, Some(Commands::Config { cmd }) => config::cmd(cmd).await, Some(Commands::Widgets { cmd }) => smooai::widgets::cmd(cmd).await, Some(Commands::Crawl { cmd }) => smooai::crawl::cmd(cmd).await, diff --git a/crates/smooth-cli/src/smooai/observability.rs b/crates/smooth-cli/src/smooai/observability.rs index c729bf48..d995699e 100644 --- a/crates/smooth-cli/src/smooai/observability.rs +++ b/crates/smooth-cli/src/smooai/observability.rs @@ -59,6 +59,70 @@ pub enum Cmd { #[arg(long = "org-id", visible_alias = "org")] org: Option, }, + /// Error tracking — list and triage the error groups the dashboard + /// dedups by fingerprint. `list` to see what's firing, `show` for the + /// rich detail + recent events, `ok`/`not-ok`/`mute` to set the review + /// status (resolved / unresolved / muted). + Errors { + #[command(subcommand)] + cmd: ErrorsCmd, + }, +} + +#[derive(Subcommand)] +pub enum ErrorsCmd { + /// List error groups, most-recently-seen first (the triage view). + List { + /// Only show groups with this review status: `unresolved` (not-ok), + /// `resolved` (ok), or `muted`. Default: all. + #[arg(long)] + status: Option, + /// Filter to one environment (`production`, `staging`, `development`). + #[arg(long)] + environment: Option, + /// Max groups to return. + #[arg(long, default_value_t = 50)] + limit: u32, + /// Pagination cursor (`nextCursor` from a previous page). + #[arg(long)] + cursor: Option, + /// Print raw JSON instead of the table. + #[arg(long)] + json: bool, + /// Override the active org. Falls back to `SMOOAI_ORG_ID` then the credentials file's `active_org_id`. + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Show one error group + its recent events (full JSON detail). + Show { + /// Error group id (the `id` from `errors list`). + group_id: String, + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Mark a group OK — you've reviewed it and it's fine (status → resolved). + Ok { + /// Error group id (the `id` from `errors list`). + group_id: String, + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Mark a group NOT OK — it needs attention (status → unresolved). + #[command(name = "not-ok")] + NotOk { + /// Error group id (the `id` from `errors list`). + group_id: String, + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, + /// Mute a group — known/accepted, hidden from the default triage view + /// (status → muted). + Mute { + /// Error group id (the `id` from `errors list`). + group_id: String, + #[arg(long = "org-id", visible_alias = "org")] + org: Option, + }, } pub async fn cmd(cmd: Cmd) -> Result<()> { @@ -181,10 +245,146 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { let resp = client.get(&path).await.context("GET sourcemaps/list")?; super::print_json(&resp); } + + Cmd::Errors { cmd } => errors_cmd(&client, cmd).await?, + } + Ok(()) +} + +/// Map the friendly review verbs to the backend `error_group_status` enum. +fn status_for_verb(verb: &str) -> &'static str { + match verb { + "ok" => "resolved", + "not-ok" => "unresolved", + "mute" => "muted", + _ => unreachable!("status_for_verb called with {verb}"), + } +} + +async fn errors_cmd(client: &smooth_api_client::SmoothApiClient, cmd: ErrorsCmd) -> Result<()> { + match cmd { + ErrorsCmd::List { + status, + environment, + limit, + cursor, + json, + org, + } => { + let org = require_active_org(client, org)?; + let mut qs = format!("limit={limit}"); + if let Some(s) = &status { + qs.push_str(&format!("&status={}", urlencoding::encode(s))); + } + if let Some(e) = &environment { + qs.push_str(&format!("&environment={}", urlencoding::encode(e))); + } + if let Some(c) = &cursor { + qs.push_str(&format!("&cursor={}", urlencoding::encode(c))); + } + let resp = client + .get(&format!("/organizations/{org}/observability/errors?{qs}")) + .await + .context("GET observability/errors")?; + if json { + super::print_json(&resp); + return Ok(()); + } + print_error_groups(&resp); + } + + ErrorsCmd::Show { group_id, org } => { + let org = require_active_org(client, org)?; + let resp = client + .get(&format!("/organizations/{org}/observability/errors/{group_id}")) + .await + .context("GET observability/errors/:id")?; + super::print_json(&resp); + } + + // ok / not-ok / mute all PATCH the same status field — the only + // difference is the target enum value. + ErrorsCmd::Ok { group_id, org } => set_status(client, org, &group_id, "ok").await?, + ErrorsCmd::NotOk { group_id, org } => set_status(client, org, &group_id, "not-ok").await?, + ErrorsCmd::Mute { group_id, org } => set_status(client, org, &group_id, "mute").await?, } Ok(()) } +async fn set_status(client: &smooth_api_client::SmoothApiClient, org: Option, group_id: &str, verb: &str) -> Result<()> { + let org = require_active_org(client, org)?; + let status = status_for_verb(verb); + let updated = client + .patch( + &format!("/organizations/{org}/observability/errors/{group_id}"), + &serde_json::json!({ "status": status }), + ) + .await + .with_context(|| format!("PATCH observability/errors/{group_id}"))?; + let title = updated.get("title").and_then(|v| v.as_str()).unwrap_or("(unknown)"); + println!(); + println!(" {} {} → {}", "✓".green(), title.bold(), status.cyan()); + println!(" {} {}", "group:".dimmed(), group_id.dimmed()); + println!(); + Ok(()) +} + +/// Render the `{ groups: [...], nextCursor }` list as a compact triage table. +fn print_error_groups(resp: &serde_json::Value) { + let groups = resp.get("groups").and_then(|g| g.as_array()).cloned().unwrap_or_default(); + println!(); + if groups.is_empty() { + println!(" {} no error groups", "●".dimmed()); + println!(); + return; + } + for g in &groups { + let id = g.get("id").and_then(|v| v.as_str()).unwrap_or("?"); + let status = g.get("status").and_then(|v| v.as_str()).unwrap_or("?"); + let level = g.get("level").and_then(|v| v.as_str()).unwrap_or("?"); + let env = g.get("environment").and_then(|v| v.as_str()).unwrap_or("?"); + let count = g.get("eventCount").and_then(|v| v.as_i64()).unwrap_or(0); + let users = g.get("userCount").and_then(|v| v.as_i64()).unwrap_or(0); + let last_seen = g.get("lastSeenAt").and_then(|v| v.as_str()).unwrap_or(""); + let title = g.get("title").and_then(|v| v.as_str()).unwrap_or("(untitled)"); + let culprit = g.get("culprit").and_then(|v| v.as_str()).unwrap_or(""); + + // status glyph: unresolved = needs attention (red), resolved = ok + // (green), muted = dim. + let dot = match status { + "resolved" => "●".green().to_string(), + "muted" => "●".dimmed().to_string(), + _ => "●".red().to_string(), + }; + println!(" {dot} {}", title.bold()); + println!( + " {} {} {} {} {} {} {} {} {} {}", + "id:".dimmed(), + id.dimmed(), + "status:".dimmed(), + status, + "level:".dimmed(), + level, + "env:".dimmed(), + env, + "seen:".dimmed(), + format!("{count}× / {users} users").dimmed(), + ); + if !culprit.is_empty() { + println!(" {} {}", "at:".dimmed(), culprit.dimmed()); + } + if !last_seen.is_empty() { + println!(" {} {}", "last:".dimmed(), last_seen.dimmed()); + } + println!(); + } + println!(" {} {} group{}", "●".cyan(), groups.len().bold(), if groups.len() == 1 { "" } else { "s" }); + if let Some(next) = resp.get("nextCursor").and_then(|v| v.as_str()) { + println!(" {} --cursor {}", "more:".dimmed(), next.dimmed()); + } + println!(); +} + /// Walk `root` and return every file ending in `.map` whose stripped /// extension corresponds to a JavaScript bundle (`.js`, `.mjs`, `.cjs`). fn find_sourcemaps(root: &Path) -> Result> { @@ -211,3 +411,18 @@ fn find_sourcemaps(root: &Path) -> Result> { out.sort(); Ok(out) } + +#[cfg(test)] +mod tests { + use super::status_for_verb; + + // The friendly review verbs must map to the exact backend + // `error_group_status` enum values the PATCH endpoint validates — + // a drift here would send an invalid status and 400. + #[test] + fn verbs_map_to_backend_status() { + assert_eq!(status_for_verb("ok"), "resolved"); + assert_eq!(status_for_verb("not-ok"), "unresolved"); + assert_eq!(status_for_verb("mute"), "muted"); + } +}