Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/smooth-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
215 changes: 215 additions & 0 deletions crates/smooth-cli/src/smooai/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,70 @@ pub enum Cmd {
#[arg(long = "org-id", visible_alias = "org")]
org: Option<String>,
},
/// 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<String>,
/// Filter to one environment (`production`, `staging`, `development`).
#[arg(long)]
environment: Option<String>,
/// Max groups to return.
#[arg(long, default_value_t = 50)]
limit: u32,
/// Pagination cursor (`nextCursor` from a previous page).
#[arg(long)]
cursor: Option<String>,
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
/// 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<String>,
},
}

pub async fn cmd(cmd: Cmd) -> Result<()> {
Expand Down Expand Up @@ -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<String>, 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<Vec<PathBuf>> {
Expand All @@ -211,3 +411,18 @@ fn find_sourcemaps(root: &Path) -> Result<Vec<PathBuf>> {
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");
}
}
Loading