diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed6d65a09..5b75819f1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add debugging/triage CLI commands: `issues info`, `issues events`, `issues tags`, `traces info`, and `events attachment` ([#3087](https://github.com/getsentry/sentry-cli/pull/3087)) + ## 3.1.0 ### New Features diff --git a/src/api/mod.rs b/src/api/mod.rs index 7dce776cae..d805015282 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -346,7 +346,8 @@ impl Api { warn!("Unable to find release file"); Ok(None) } else { - info!("Release registry returned {}", resp.status()); + let status = resp.status(); + info!("Release registry returned {status}"); Ok(None) } } @@ -941,6 +942,132 @@ impl AuthenticatedApi<'_> { Ok(rv) } + /// Get detailed information about a specific issue + pub fn get_issue_details(&self, org: &str, issue_id: &str) -> ApiResult { + let path = format!( + "/organizations/{}/issues/{}/", + PathArg(org), + PathArg(issue_id) + ); + self.get(&path)?.convert_rnf(ApiErrorKind::ResourceNotFound) + } + + /// Get the latest event for an issue + pub fn get_issue_latest_event( + &self, + org: &str, + issue_id: &str, + ) -> ApiResult> { + let path = format!( + "/organizations/{}/issues/{}/events/latest/", + PathArg(org), + PathArg(issue_id) + ); + let resp = self.get(&path)?; + if resp.status() == 404 { + Ok(None) + } else { + resp.convert() + } + } + + /// List events for a specific issue + pub fn list_issue_events( + &self, + org: &str, + issue_id: &str, + limit: Option, + sort: Option<&str>, + stats_period: Option<&str>, + ) -> ApiResult> { + let limit = limit.unwrap_or(50); + let sort = sort.unwrap_or("-timestamp"); + let stats_period = stats_period.unwrap_or("14d"); + let org_arg = PathArg(org); + let issue_arg = PathArg(issue_id); + let sort_arg = QueryArg(sort); + let stats_arg = QueryArg(stats_period); + let path = format!( + "/organizations/{org_arg}/issues/{issue_arg}/events/?per_page={limit}&sort={sort_arg}&statsPeriod={stats_arg}" + ); + self.get(&path)?.convert_rnf(ApiErrorKind::ResourceNotFound) + } + + /// Get tag value distribution for an issue + pub fn get_issue_tag_values( + &self, + org: &str, + issue_id: &str, + tag_key: &str, + ) -> ApiResult { + let path = format!( + "/organizations/{}/issues/{}/tags/{}/", + PathArg(org), + PathArg(issue_id), + PathArg(tag_key) + ); + self.get(&path)?.convert_rnf(ApiErrorKind::ResourceNotFound) + } + + /// Get trace metadata + pub fn get_trace_meta(&self, org: &str, trace_id: &str) -> ApiResult { + let path = format!( + "/organizations/{}/events-trace-meta/{}/", + PathArg(org), + PathArg(trace_id) + ); + self.get(&path)?.convert_rnf(ApiErrorKind::ResourceNotFound) + } + + /// Get trace span tree + pub fn get_trace(&self, org: &str, trace_id: &str) -> ApiResult> { + let path = format!( + "/organizations/{}/events-trace/{}/?limit=100&statsPeriod=14d", + PathArg(org), + PathArg(trace_id) + ); + self.get(&path)?.convert_rnf(ApiErrorKind::ResourceNotFound) + } + + /// List attachments for an event + pub fn list_event_attachments( + &self, + org: &str, + project: &str, + event_id: &str, + ) -> ApiResult> { + let path = format!( + "/projects/{}/{}/events/{}/attachments/", + PathArg(org), + PathArg(project), + PathArg(event_id) + ); + self.get(&path)?.convert_rnf(ApiErrorKind::ProjectNotFound) + } + + /// Download a specific attachment + pub fn download_event_attachment( + &self, + org: &str, + project: &str, + event_id: &str, + attachment_id: &str, + ) -> ApiResult> { + let path = format!( + "/projects/{}/{}/events/{}/attachments/{}/?download=1", + PathArg(org), + PathArg(project), + PathArg(event_id), + PathArg(attachment_id) + ); + let resp = self.get(&path)?; + if resp.status() == 404 { + return Err(ApiErrorKind::ResourceNotFound.into()); + } + let resp = resp.into_result()?; + Ok(resp.body.unwrap_or_default()) + } + /// List all repos associated with an organization pub fn list_organization_repos(&self, org: &str) -> ApiResult> { let mut rv = vec![]; @@ -986,7 +1113,8 @@ impl Dataset { impl fmt::Display for Dataset { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.as_str()) + let s = self.as_str(); + write!(f, "{s}") } } @@ -1682,6 +1810,102 @@ pub struct Issue { pub level: String, } +/// Detailed issue information from the API +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueDetails { + #[expect(dead_code, reason = "API response field")] + pub id: String, + pub short_id: String, + pub title: String, + pub culprit: Option, + pub status: String, + pub level: String, + pub count: String, + pub user_count: u64, + pub first_seen: DateTime, + pub last_seen: DateTime, + pub permalink: String, +} + +/// Latest event for an issue +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueLatestEvent { + #[serde(alias = "eventID")] + pub event_id: String, + #[expect(dead_code, reason = "API response field")] + pub title: Option, + #[serde(alias = "dateCreated")] + pub date_created: Option, + pub tags: Option>, +} + +/// Tag value distribution for an issue +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueTagValues { + #[expect(dead_code, reason = "API response field")] + pub key: String, + pub name: String, + pub total_values: u64, + pub top_values: Vec, +} + +/// Individual tag value with count +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueTagValue { + pub value: Option, + pub count: u64, + #[expect(dead_code, reason = "API response field")] + pub first_seen: Option>, + #[expect(dead_code, reason = "API response field")] + pub last_seen: Option>, +} + +/// Trace metadata summary +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TraceMeta { + pub span_count: Option, + pub errors: Option, + pub performance_issues: Option, +} + +/// Span in a trace tree +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TraceSpan { + #[expect(dead_code, reason = "API response field")] + pub event_id: Option, + pub op: Option, + pub description: Option, + #[serde(default)] + pub duration: Option, + #[serde(default)] + #[expect(dead_code, reason = "API response field")] + pub is_transaction: bool, + #[serde(default)] + pub children: Vec, + #[serde(default)] + pub errors: Vec, +} + +/// Event attachment metadata +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventAttachment { + pub id: String, + pub name: String, + #[serde(rename = "type")] + pub attachment_type: String, + pub size: u64, + pub mimetype: Option, + #[expect(dead_code, reason = "API response field")] + pub date_created: Option>, +} + /// Change information for issue bulk updates. #[derive(Serialize, Default)] pub struct IssueChanges { diff --git a/src/commands/events/attachment.rs b/src/commands/events/attachment.rs new file mode 100644 index 0000000000..5d9b057c14 --- /dev/null +++ b/src/commands/events/attachment.rs @@ -0,0 +1,110 @@ +use std::fs::File; +use std::io::Write as _; +use std::path::Path; + +use anyhow::{bail, Result}; +use clap::{Arg, ArgMatches, Command}; + +use crate::api::Api; +use crate::config::Config; +use crate::utils::formatting::Table; + +pub fn make_command(command: Command) -> Command { + command + .about("List or download attachments for an event.") + .arg( + Arg::new("event_id") + .required(true) + .value_name("EVENT_ID") + .help("The event ID."), + ) + .arg( + Arg::new("attachment_id") + .value_name("ATTACHMENT_ID") + .help("The attachment ID to download (optional, lists all if omitted)."), + ) + .arg( + Arg::new("output") + .long("output") + .short('o') + .value_name("PATH") + .help("Output file path for download (required when downloading)."), + ) +} + +pub fn execute(matches: &ArgMatches) -> Result<()> { + let config = Config::current(); + let org = config.get_org(matches)?; + let project = config.get_project(matches)?; + let event_id = matches + .get_one::("event_id") + .expect("event_id is required"); + let attachment_id = matches.get_one::("attachment_id"); + let output_path = matches.get_one::("output"); + + let api = Api::current(); + let authenticated = api.authenticated()?; + + match attachment_id { + None => { + // List mode + let attachments = authenticated.list_event_attachments(&org, &project, event_id)?; + + if attachments.is_empty() { + println!("No attachments found for event {event_id}"); + return Ok(()); + } + + println!("Attachments for event {event_id}:"); + println!(); + + let mut table = Table::new(); + table + .title_row() + .add("ID") + .add("Name") + .add("Type") + .add("Size"); + + for att in &attachments { + let size = format_size(att.size); + table + .add_row() + .add(&att.id) + .add(&att.name) + .add(att.mimetype.as_deref().unwrap_or(&att.attachment_type)) + .add(&size); + } + + table.print(); + } + Some(att_id) => { + // Download mode + let output = match output_path { + Some(p) => p.clone(), + None => bail!("--output is required when downloading an attachment"), + }; + + let data = authenticated.download_event_attachment(&org, &project, event_id, att_id)?; + + let path = Path::new(&output); + let mut file = File::create(path)?; + file.write_all(&data)?; + + let size = format_size(data.len() as u64); + println!("Downloaded: {output} ({size})"); + } + } + + Ok(()) +} + +fn format_size(bytes: u64) -> String { + if bytes >= 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } else if bytes >= 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{bytes} B") + } +} diff --git a/src/commands/events/mod.rs b/src/commands/events/mod.rs index 563d1bc47d..47d03f2cba 100644 --- a/src/commands/events/mod.rs +++ b/src/commands/events/mod.rs @@ -3,10 +3,12 @@ use clap::{ArgMatches, Command}; use crate::utils::args::ArgExt as _; +pub mod attachment; pub mod list; macro_rules! each_subcommand { ($mac:ident) => { + $mac!(attachment); $mac!(list); }; } diff --git a/src/commands/issues/events.rs b/src/commands/issues/events.rs new file mode 100644 index 0000000000..a3d9f8038b --- /dev/null +++ b/src/commands/issues/events.rs @@ -0,0 +1,102 @@ +use anyhow::Result; +use clap::{Arg, ArgMatches, Command}; + +use crate::api::Api; +use crate::config::Config; +use crate::utils::formatting::Table; + +pub fn make_command(command: Command) -> Command { + command + .about("List events for a specific issue.") + .arg( + Arg::new("issue_id") + .required(true) + .value_name("ISSUE_ID") + .help("The issue ID (e.g., PROJ-123 or full UUID)."), + ) + .arg( + Arg::new("limit") + .long("limit") + .short('l') + .value_name("LIMIT") + .default_value("50") + .value_parser(clap::value_parser!(usize)) + .help("Maximum number of events to return."), + ) + .arg( + Arg::new("sort") + .long("sort") + .value_name("SORT") + .default_value("-timestamp") + .help("Sort field (e.g., -timestamp, timestamp)."), + ) + .arg( + Arg::new("period") + .long("period") + .value_name("PERIOD") + .default_value("14d") + .help("Time period (e.g., 24h, 7d, 14d)."), + ) +} + +pub fn execute(matches: &ArgMatches) -> Result<()> { + let config = Config::current(); + let org = config.get_org(matches)?; + let issue_id = matches + .get_one::("issue_id") + .expect("issue_id is required"); + let limit = *matches + .get_one::("limit") + .expect("limit has default value"); + let sort = matches.get_one::("sort").map(|s| s.as_str()); + let period = matches.get_one::("period").map(|s| s.as_str()); + + let api = Api::current(); + let events = + api.authenticated()? + .list_issue_events(&org, issue_id, Some(limit), sort, period)?; + + if events.is_empty() { + println!("No events found for issue {issue_id}"); + return Ok(()); + } + + let event_count = events.len(); + println!("Events for {issue_id} (showing {event_count}):"); + println!(); + + let mut table = Table::new(); + table + .title_row() + .add("Event ID") + .add("Timestamp") + .add("Environment") + .add("Release"); + + for event in &events { + let env = event + .tags + .as_ref() + .and_then(|tags| tags.iter().find(|t| t.key == "environment")) + .map(|t| t.value.as_str()) + .unwrap_or("-"); + let release = event + .tags + .as_ref() + .and_then(|tags| tags.iter().find(|t| t.key == "release")) + .map(|t| t.value.as_str()) + .unwrap_or("-"); + let timestamp = event.date_created.as_deref().unwrap_or("-"); + + table + .add_row() + .add(&event.event_id) + .add(timestamp) + .add(env) + .add(release); + } + + table.print(); + + Ok(()) +} diff --git a/src/commands/issues/info.rs b/src/commands/issues/info.rs new file mode 100644 index 0000000000..bd2ed4dd0c --- /dev/null +++ b/src/commands/issues/info.rs @@ -0,0 +1,63 @@ +use anyhow::Result; +use clap::{Arg, ArgMatches, Command}; + +use crate::api::Api; +use crate::config::Config; + +pub fn make_command(command: Command) -> Command { + command + .about("Get detailed information about a specific issue.") + .arg( + Arg::new("issue_id") + .required(true) + .value_name("ISSUE_ID") + .help("The issue ID (e.g., PROJ-123 or full UUID)."), + ) +} + +pub fn execute(matches: &ArgMatches) -> Result<()> { + let config = Config::current(); + let org = config.get_org(matches)?; + let issue_id = matches + .get_one::("issue_id") + .expect("issue_id is required"); + + let api = Api::current(); + let authenticated = api.authenticated()?; + + let issue = authenticated.get_issue_details(&org, issue_id)?; + let latest_event = authenticated + .get_issue_latest_event(&org, issue_id) + .ok() + .flatten(); + + println!("Issue: {}", issue.short_id); + println!("Title: {}", issue.title); + if let Some(culprit) = &issue.culprit { + println!("Culprit: {culprit}"); + } + println!("Status: {}", issue.status); + println!("Level: {}", issue.level); + println!("Events: {}", issue.count); + println!("Users: {}", issue.user_count); + println!("First Seen: {}", issue.first_seen); + println!("Last Seen: {}", issue.last_seen); + println!("Link: {}", issue.permalink); + + if let Some(event) = latest_event { + println!(); + println!("Latest Event: {}", event.event_id); + if let Some(date) = &event.date_created { + println!(" Timestamp: {date}"); + } + if let Some(tags) = &event.tags { + for tag in tags { + if tag.key == "environment" || tag.key == "release" { + println!(" {}: {}", tag.key, tag.value); + } + } + } + } + + Ok(()) +} diff --git a/src/commands/issues/mod.rs b/src/commands/issues/mod.rs index ddb519c6b5..cbb3c3478d 100644 --- a/src/commands/issues/mod.rs +++ b/src/commands/issues/mod.rs @@ -3,16 +3,22 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use crate::utils::args::ArgExt as _; +pub mod events; +pub mod info; pub mod list; pub mod mute; pub mod resolve; +pub mod tags; pub mod unresolve; macro_rules! each_subcommand { ($mac:ident) => { + $mac!(events); + $mac!(info); $mac!(list); $mac!(mute); $mac!(resolve); + $mac!(tags); $mac!(unresolve); }; } diff --git a/src/commands/issues/tags.rs b/src/commands/issues/tags.rs new file mode 100644 index 0000000000..491a906ca1 --- /dev/null +++ b/src/commands/issues/tags.rs @@ -0,0 +1,65 @@ +use anyhow::Result; +use clap::{Arg, ArgMatches, Command}; + +use crate::api::Api; +use crate::config::Config; + +pub fn make_command(command: Command) -> Command { + command + .about("Get tag value distribution for an issue.") + .arg( + Arg::new("issue_id") + .required(true) + .value_name("ISSUE_ID") + .help("The issue ID (e.g., PROJ-123 or full UUID)."), + ) + .arg( + Arg::new("key") + .long("key") + .short('k') + .required(true) + .value_name("TAG_KEY") + .help("The tag key to get values for (e.g., browser, os, environment)."), + ) +} + +pub fn execute(matches: &ArgMatches) -> Result<()> { + let config = Config::current(); + let org = config.get_org(matches)?; + let issue_id = matches + .get_one::("issue_id") + .expect("issue_id is required"); + let tag_key = matches.get_one::("key").expect("key is required"); + + let api = Api::current(); + let tag_values = api + .authenticated()? + .get_issue_tag_values(&org, issue_id, tag_key)?; + + println!( + "Tag: {} ({} unique values)", + tag_values.name, tag_values.total_values + ); + println!(); + + if tag_values.top_values.is_empty() { + println!("No values found for tag '{tag_key}'"); + return Ok(()); + } + + // Calculate total for percentage + let total: u64 = tag_values.top_values.iter().map(|v| v.count).sum(); + + for value in &tag_values.top_values { + let display_value = value.value.as_deref().unwrap_or("(none)"); + let percentage = if total > 0 { + (value.count as f64 / total as f64) * 100.0 + } else { + 0.0 + }; + let count = value.count; + println!(" {display_value:30} {count:>8} events ({percentage:.0}%)"); + } + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index dac94c33c7..237ccdad51 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -39,6 +39,7 @@ mod repos; mod send_envelope; mod send_event; mod sourcemaps; +mod traces; #[cfg(not(feature = "managed"))] mod uninstall; #[cfg(not(feature = "managed"))] @@ -67,6 +68,7 @@ macro_rules! each_subcommand { $mac!(send_event); $mac!(send_envelope); $mac!(sourcemaps); + $mac!(traces); $mac!(dart_symbol_map); #[cfg(not(feature = "managed"))] $mac!(uninstall); diff --git a/src/commands/traces/mod.rs b/src/commands/traces/mod.rs new file mode 100644 index 0000000000..f24df08044 --- /dev/null +++ b/src/commands/traces/mod.rs @@ -0,0 +1,86 @@ +use anyhow::Result; +use clap::{Arg, ArgMatches, Command}; + +use crate::api::{Api, TraceSpan}; +use crate::config::Config; +use crate::utils::args::ArgExt as _; + +pub fn make_command(command: Command) -> Command { + command + .about("Manage traces in Sentry.") + .subcommand_required(true) + .arg_required_else_help(true) + .org_arg() + .subcommand( + Command::new("info") + .about("Get detailed information about a trace.") + .arg( + Arg::new("trace_id") + .required(true) + .value_name("TRACE_ID") + .help("The trace ID."), + ), + ) +} + +pub fn execute(matches: &ArgMatches) -> Result<()> { + if let Some(sub_matches) = matches.subcommand_matches("info") { + return execute_info(sub_matches); + } + unreachable!(); +} + +fn execute_info(matches: &ArgMatches) -> Result<()> { + let config = Config::current(); + let org = config.get_org(matches)?; + let trace_id = matches + .get_one::("trace_id") + .expect("trace_id is required"); + + let api = Api::current(); + let authenticated = api.authenticated()?; + + let meta = authenticated.get_trace_meta(&org, trace_id)?; + let spans = authenticated.get_trace(&org, trace_id)?; + + println!("Trace: {trace_id}"); + println!(); + println!("Summary:"); + println!(" Spans: {}", meta.span_count.unwrap_or(0)); + println!(" Errors: {}", meta.errors.unwrap_or(0)); + println!( + " Performance Issues: {}", + meta.performance_issues.unwrap_or(0) + ); + + if !spans.is_empty() { + println!(); + println!("Trace Tree:"); + for span in &spans { + print_span(span, 0); + } + } + + Ok(()) +} + +fn print_span(span: &TraceSpan, depth: usize) { + let indent = " ".repeat(depth); + let prefix = if depth == 0 { "-" } else { "|-" }; + + let op = span.op.as_deref().unwrap_or("unknown"); + let desc = span.description.as_deref().unwrap_or(""); + let duration = span + .duration + .map(|d| format!(" ({d:.0}ms)")) + .unwrap_or_default(); + + let has_error = !span.errors.is_empty(); + let error_marker = if has_error { " [error]" } else { "" }; + + println!("{indent}{prefix} [{op}] {desc}{duration}{error_marker}"); + + for child in &span.children { + print_span(child, depth + 1); + } +} diff --git a/tests/integration/_cases/events/events-help.trycmd b/tests/integration/_cases/events/events-help.trycmd index 4bd3082026..9438c8a419 100644 --- a/tests/integration/_cases/events/events-help.trycmd +++ b/tests/integration/_cases/events/events-help.trycmd @@ -6,8 +6,9 @@ Manage events on Sentry. Usage: sentry-cli[EXE] events [OPTIONS] Commands: - list List all events in your organization. - help Print this message or the help of the given subcommand(s) + attachment List or download attachments for an event. + list List all events in your organization. + help Print this message or the help of the given subcommand(s) Options: -o, --org The organization ID or slug. diff --git a/tests/integration/_cases/events/events-no-subcommand.trycmd b/tests/integration/_cases/events/events-no-subcommand.trycmd index 9d8cbf5aed..e2eeee70e4 100644 --- a/tests/integration/_cases/events/events-no-subcommand.trycmd +++ b/tests/integration/_cases/events/events-no-subcommand.trycmd @@ -6,8 +6,9 @@ Manage events on Sentry. Usage: sentry-cli[EXE] events [OPTIONS] Commands: - list List all events in your organization. - help Print this message or the help of the given subcommand(s) + attachment List or download attachments for an event. + list List all events in your organization. + help Print this message or the help of the given subcommand(s) Options: -o, --org The organization ID or slug. diff --git a/tests/integration/_cases/help/help-windows.trycmd b/tests/integration/_cases/help/help-windows.trycmd index 7b98475db6..f956b758a2 100644 --- a/tests/integration/_cases/help/help-windows.trycmd +++ b/tests/integration/_cases/help/help-windows.trycmd @@ -28,6 +28,7 @@ Commands: send-event Send a manual event to Sentry. send-envelope Send a stored envelope to Sentry. sourcemaps Manage sourcemaps for Sentry releases. + traces Manage traces in Sentry. dart-symbol-map Manage Dart/Flutter symbol maps for Sentry. upload-proguard Upload ProGuard mapping files to a project. help Print this message or the help of the given subcommand(s) diff --git a/tests/integration/_cases/help/help.trycmd b/tests/integration/_cases/help/help.trycmd index 5061a03fc8..fb0854e828 100644 --- a/tests/integration/_cases/help/help.trycmd +++ b/tests/integration/_cases/help/help.trycmd @@ -28,6 +28,7 @@ Commands: send-event Send a manual event to Sentry. send-envelope Send a stored envelope to Sentry. sourcemaps Manage sourcemaps for Sentry releases. + traces Manage traces in Sentry. dart-symbol-map Manage Dart/Flutter symbol maps for Sentry. uninstall Uninstall the sentry-cli executable. upload-proguard Upload ProGuard mapping files to a project. diff --git a/tests/integration/_cases/issues/issues-help.trycmd b/tests/integration/_cases/issues/issues-help.trycmd index 71bac53175..b2970b0d0e 100644 --- a/tests/integration/_cases/issues/issues-help.trycmd +++ b/tests/integration/_cases/issues/issues-help.trycmd @@ -7,9 +7,12 @@ Manage issues in Sentry. Usage: sentry-cli[EXE] issues [OPTIONS] Commands: + events List events for a specific issue. + info Get detailed information about a specific issue. list List all issues in your organization. mute Bulk mute all selected issues. resolve Bulk resolve all selected issues. + tags Get tag value distribution for an issue. unresolve Bulk unresolve all selected issues. help Print this message or the help of the given subcommand(s)