From b6e47c39af11ba384d54a812e845476cbca77828 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 15 Jan 2026 10:40:01 +0100 Subject: [PATCH 01/12] feat(api): add data types for debugging commands Add IssueDetails, IssueLatestEvent, IssueTagValues, TraceMeta, TraceSpan, and EventAttachment structs for the new debugging CLI commands. --- src/api/mod.rs | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/api/mod.rs b/src/api/mod.rs index 7dce776cae..fad3e96e86 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1682,6 +1682,94 @@ pub struct Issue { pub level: String, } +/// Detailed issue information from the API +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssueDetails { + 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, + 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 { + 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, + pub first_seen: Option>, + 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 { + pub event_id: Option, + pub op: Option, + pub description: Option, + #[serde(default)] + pub duration: Option, + #[serde(default)] + 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, + pub date_created: Option>, +} + /// Change information for issue bulk updates. #[derive(Serialize, Default)] pub struct IssueChanges { From 42c8567c99cdd361914ffdd237e659e0877fbdd1 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 15 Jan 2026 10:43:50 +0100 Subject: [PATCH 02/12] feat(api): add issue debugging API methods Add get_issue_details, get_issue_latest_event, list_issue_events, and get_issue_tag_values methods to AuthenticatedApi. --- src/api/mod.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/api/mod.rs b/src/api/mod.rs index fad3e96e86..c95f67caa6 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -941,6 +941,74 @@ 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 path = format!( + "/organizations/{}/issues/{}/events/?per_page={}&sort={}&statsPeriod={}", + PathArg(org), + PathArg(issue_id), + limit, + QueryArg(sort), + QueryArg(stats_period) + ); + 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) + } + /// List all repos associated with an organization pub fn list_organization_repos(&self, org: &str) -> ApiResult> { let mut rv = vec![]; From bbac70d95a75b81c00cc25ab5a477490388aebff Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 15 Jan 2026 10:46:44 +0100 Subject: [PATCH 03/12] feat(api): add trace and attachment API methods Add get_trace_meta, get_trace, list_event_attachments, and download_event_attachment methods to AuthenticatedApi. --- src/api/mod.rs | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/api/mod.rs b/src/api/mod.rs index c95f67caa6..56b6673998 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1009,6 +1009,65 @@ impl AuthenticatedApi<'_> { 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![]; From ad2a8aa8a8e00f6bb2ac452a712463c43abe09a7 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 15 Jan 2026 10:50:13 +0100 Subject: [PATCH 04/12] feat(cli): add issues info command Add 'sentry-cli issues info ' command to get detailed information about a specific issue including the latest event. --- src/commands/issues/info.rs | 61 +++++++++++++++++++++++++++++++++++++ src/commands/issues/mod.rs | 2 ++ 2 files changed, 63 insertions(+) create mode 100644 src/commands/issues/info.rs diff --git a/src/commands/issues/info.rs b/src/commands/issues/info.rs new file mode 100644 index 0000000000..807af12a28 --- /dev/null +++ b/src/commands/issues/info.rs @@ -0,0 +1,61 @@ +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").unwrap(); + + 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..0a37f5a099 100644 --- a/src/commands/issues/mod.rs +++ b/src/commands/issues/mod.rs @@ -3,6 +3,7 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use crate::utils::args::ArgExt as _; +pub mod info; pub mod list; pub mod mute; pub mod resolve; @@ -10,6 +11,7 @@ pub mod unresolve; macro_rules! each_subcommand { ($mac:ident) => { + $mac!(info); $mac!(list); $mac!(mute); $mac!(resolve); From 0f920ee8047200cb5af43395e07285869e5afd3f Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 15 Jan 2026 10:51:55 +0100 Subject: [PATCH 05/12] feat(cli): add issues events command Add 'sentry-cli issues events ' command to list events within a specific issue with filtering options. --- src/commands/issues/events.rs | 97 +++++++++++++++++++++++++++++++++++ src/commands/issues/mod.rs | 2 + 2 files changed, 99 insertions(+) create mode 100644 src/commands/issues/events.rs diff --git a/src/commands/issues/events.rs b/src/commands/issues/events.rs new file mode 100644 index 0000000000..d07fbfb56c --- /dev/null +++ b/src/commands/issues/events.rs @@ -0,0 +1,97 @@ +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").unwrap(); + let limit = *matches.get_one::("limit").unwrap(); + 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(()); + } + + println!("Events for {} (showing {}):", issue_id, events.len()); + 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/mod.rs b/src/commands/issues/mod.rs index 0a37f5a099..f463f16684 100644 --- a/src/commands/issues/mod.rs +++ b/src/commands/issues/mod.rs @@ -3,6 +3,7 @@ use clap::{Arg, ArgAction, ArgMatches, Command}; use crate::utils::args::ArgExt as _; +pub mod events; pub mod info; pub mod list; pub mod mute; @@ -11,6 +12,7 @@ pub mod unresolve; macro_rules! each_subcommand { ($mac:ident) => { + $mac!(events); $mac!(info); $mac!(list); $mac!(mute); From 9ac64ece8851ecfeefc0464f8058e574407462c1 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 15 Jan 2026 10:53:11 +0100 Subject: [PATCH 06/12] feat(cli): add issues tags command Add 'sentry-cli issues tags --key ' command to get tag value distribution for a specific issue. --- src/commands/issues/mod.rs | 2 ++ src/commands/issues/tags.rs | 65 +++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 src/commands/issues/tags.rs diff --git a/src/commands/issues/mod.rs b/src/commands/issues/mod.rs index f463f16684..cbb3c3478d 100644 --- a/src/commands/issues/mod.rs +++ b/src/commands/issues/mod.rs @@ -8,6 +8,7 @@ pub mod info; pub mod list; pub mod mute; pub mod resolve; +pub mod tags; pub mod unresolve; macro_rules! each_subcommand { @@ -17,6 +18,7 @@ macro_rules! each_subcommand { $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..213fbe5ec4 --- /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").unwrap(); + let tag_key = matches.get_one::("key").unwrap(); + + 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 + }; + println!( + " {:30} {:>8} events ({:.0}%)", + display_value, value.count, percentage + ); + } + + Ok(()) +} From 9cdf0741dcf0ca4fc75bc3c28e98b8b85d3f47b1 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 15 Jan 2026 10:54:47 +0100 Subject: [PATCH 07/12] feat(cli): add traces info command Add 'sentry-cli traces info ' command to get trace details including span tree and error information. --- src/commands/mod.rs | 2 + src/commands/traces/mod.rs | 87 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 src/commands/traces/mod.rs 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..3316bbe83d --- /dev/null +++ b/src/commands/traces/mod.rs @@ -0,0 +1,87 @@ +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").unwrap(); + + 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!(" ({:.0}ms)", d)) + .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); + } +} From 525ed001588e1b6d3aa929507d1f06b64635a13d Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 15 Jan 2026 10:56:11 +0100 Subject: [PATCH 08/12] feat(cli): add events attachment command Add 'sentry-cli events attachment [attachment-id]' command to list or download event attachments. --- src/commands/events/attachment.rs | 111 ++++++++++++++++++++++++++++++ src/commands/events/mod.rs | 2 + 2 files changed, 113 insertions(+) create mode 100644 src/commands/events/attachment.rs diff --git a/src/commands/events/attachment.rs b/src/commands/events/attachment.rs new file mode 100644 index 0000000000..13d64a4bb6 --- /dev/null +++ b/src/commands/events/attachment.rs @@ -0,0 +1,111 @@ +use std::fs::File; +use std::io::Write; +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").unwrap(); + 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)?; + + println!( + "Downloaded: {} ({})", + output, + format_size(data.len() as u64) + ); + } + } + + 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!("{} B", bytes) + } +} 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); }; } From 352370de37e176f4b1297edcf78c5c7458e6f4a1 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 15 Jan 2026 11:34:31 +0100 Subject: [PATCH 09/12] fix: address clippy warnings for debugging commands --- src/api/mod.rs | 17 +++++++++-------- src/commands/events/attachment.rs | 19 +++++++++---------- src/commands/issues/events.rs | 13 +++++++++---- src/commands/issues/info.rs | 8 +++++--- src/commands/issues/tags.rs | 14 +++++++------- src/commands/traces/mod.rs | 13 ++++++------- 6 files changed, 45 insertions(+), 39 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 56b6673998..eb421560ae 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) } } @@ -982,13 +983,12 @@ impl AuthenticatedApi<'_> { 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/{}/issues/{}/events/?per_page={}&sort={}&statsPeriod={}", - PathArg(org), - PathArg(issue_id), - limit, - QueryArg(sort), - QueryArg(stats_period) + "/organizations/{org_arg}/issues/{issue_arg}/events/?per_page={limit}&sort={sort_arg}&statsPeriod={stats_arg}" ); self.get(&path)?.convert_rnf(ApiErrorKind::ResourceNotFound) } @@ -1113,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}") } } diff --git a/src/commands/events/attachment.rs b/src/commands/events/attachment.rs index 13d64a4bb6..5d9b057c14 100644 --- a/src/commands/events/attachment.rs +++ b/src/commands/events/attachment.rs @@ -1,5 +1,5 @@ use std::fs::File; -use std::io::Write; +use std::io::Write as _; use std::path::Path; use anyhow::{bail, Result}; @@ -36,7 +36,9 @@ 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").unwrap(); + 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"); @@ -49,11 +51,11 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { let attachments = authenticated.list_event_attachments(&org, &project, event_id)?; if attachments.is_empty() { - println!("No attachments found for event {}", event_id); + println!("No attachments found for event {event_id}"); return Ok(()); } - println!("Attachments for event {}:", event_id); + println!("Attachments for event {event_id}:"); println!(); let mut table = Table::new(); @@ -89,11 +91,8 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { let mut file = File::create(path)?; file.write_all(&data)?; - println!( - "Downloaded: {} ({})", - output, - format_size(data.len() as u64) - ); + let size = format_size(data.len() as u64); + println!("Downloaded: {output} ({size})"); } } @@ -106,6 +105,6 @@ fn format_size(bytes: u64) -> String { } else if bytes >= 1024 { format!("{:.1} KB", bytes as f64 / 1024.0) } else { - format!("{} B", bytes) + format!("{bytes} B") } } diff --git a/src/commands/issues/events.rs b/src/commands/issues/events.rs index d07fbfb56c..a3d9f8038b 100644 --- a/src/commands/issues/events.rs +++ b/src/commands/issues/events.rs @@ -42,8 +42,12 @@ pub fn make_command(command: Command) -> Command { pub fn execute(matches: &ArgMatches) -> Result<()> { let config = Config::current(); let org = config.get_org(matches)?; - let issue_id = matches.get_one::("issue_id").unwrap(); - let limit = *matches.get_one::("limit").unwrap(); + 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()); @@ -53,11 +57,12 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { .list_issue_events(&org, issue_id, Some(limit), sort, period)?; if events.is_empty() { - println!("No events found for issue {}", issue_id); + println!("No events found for issue {issue_id}"); return Ok(()); } - println!("Events for {} (showing {}):", issue_id, events.len()); + let event_count = events.len(); + println!("Events for {issue_id} (showing {event_count}):"); println!(); let mut table = Table::new(); diff --git a/src/commands/issues/info.rs b/src/commands/issues/info.rs index 807af12a28..bd2ed4dd0c 100644 --- a/src/commands/issues/info.rs +++ b/src/commands/issues/info.rs @@ -18,7 +18,9 @@ pub fn make_command(command: Command) -> Command { pub fn execute(matches: &ArgMatches) -> Result<()> { let config = Config::current(); let org = config.get_org(matches)?; - let issue_id = matches.get_one::("issue_id").unwrap(); + let issue_id = matches + .get_one::("issue_id") + .expect("issue_id is required"); let api = Api::current(); let authenticated = api.authenticated()?; @@ -32,7 +34,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { println!("Issue: {}", issue.short_id); println!("Title: {}", issue.title); if let Some(culprit) = &issue.culprit { - println!("Culprit: {}", culprit); + println!("Culprit: {culprit}"); } println!("Status: {}", issue.status); println!("Level: {}", issue.level); @@ -46,7 +48,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { println!(); println!("Latest Event: {}", event.event_id); if let Some(date) = &event.date_created { - println!(" Timestamp: {}", date); + println!(" Timestamp: {date}"); } if let Some(tags) = &event.tags { for tag in tags { diff --git a/src/commands/issues/tags.rs b/src/commands/issues/tags.rs index 213fbe5ec4..491a906ca1 100644 --- a/src/commands/issues/tags.rs +++ b/src/commands/issues/tags.rs @@ -26,8 +26,10 @@ pub fn make_command(command: Command) -> Command { pub fn execute(matches: &ArgMatches) -> Result<()> { let config = Config::current(); let org = config.get_org(matches)?; - let issue_id = matches.get_one::("issue_id").unwrap(); - let tag_key = matches.get_one::("key").unwrap(); + 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 @@ -41,7 +43,7 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { println!(); if tag_values.top_values.is_empty() { - println!("No values found for tag '{}'", tag_key); + println!("No values found for tag '{tag_key}'"); return Ok(()); } @@ -55,10 +57,8 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { } else { 0.0 }; - println!( - " {:30} {:>8} events ({:.0}%)", - display_value, value.count, percentage - ); + let count = value.count; + println!(" {display_value:30} {count:>8} events ({percentage:.0}%)"); } Ok(()) diff --git a/src/commands/traces/mod.rs b/src/commands/traces/mod.rs index 3316bbe83d..f24df08044 100644 --- a/src/commands/traces/mod.rs +++ b/src/commands/traces/mod.rs @@ -33,7 +33,9 @@ pub fn execute(matches: &ArgMatches) -> Result<()> { fn execute_info(matches: &ArgMatches) -> Result<()> { let config = Config::current(); let org = config.get_org(matches)?; - let trace_id = matches.get_one::("trace_id").unwrap(); + let trace_id = matches + .get_one::("trace_id") + .expect("trace_id is required"); let api = Api::current(); let authenticated = api.authenticated()?; @@ -41,7 +43,7 @@ fn execute_info(matches: &ArgMatches) -> Result<()> { let meta = authenticated.get_trace_meta(&org, trace_id)?; let spans = authenticated.get_trace(&org, trace_id)?; - println!("Trace: {}", trace_id); + println!("Trace: {trace_id}"); println!(); println!("Summary:"); println!(" Spans: {}", meta.span_count.unwrap_or(0)); @@ -70,16 +72,13 @@ fn print_span(span: &TraceSpan, depth: usize) { let desc = span.description.as_deref().unwrap_or(""); let duration = span .duration - .map(|d| format!(" ({:.0}ms)", d)) + .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 - ); + println!("{indent}{prefix} [{op}] {desc}{duration}{error_marker}"); for child in &span.children { print_span(child, depth + 1); From 7b2994dc49733c93dc4a40a1c2f21c887a42cb20 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 15 Jan 2026 11:37:28 +0100 Subject: [PATCH 10/12] fix: add dead_code expect attributes for unused API response fields --- src/api/mod.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/api/mod.rs b/src/api/mod.rs index eb421560ae..d805015282 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1814,6 +1814,7 @@ pub struct Issue { #[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, @@ -1833,6 +1834,7 @@ pub struct IssueDetails { 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, @@ -1843,6 +1845,7 @@ pub struct IssueLatestEvent { #[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, @@ -1855,7 +1858,9 @@ pub struct IssueTagValues { 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>, } @@ -1872,12 +1877,14 @@ pub struct TraceMeta { #[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, @@ -1895,6 +1902,7 @@ pub struct EventAttachment { pub attachment_type: String, pub size: u64, pub mimetype: Option, + #[expect(dead_code, reason = "API response field")] pub date_created: Option>, } From 67b65be48f39965aa171535534694ccf55a51a87 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 15 Jan 2026 11:43:48 +0100 Subject: [PATCH 11/12] test: update CLI help test fixtures for new debugging commands --- tests/integration/_cases/events/events-help.trycmd | 5 +++-- tests/integration/_cases/events/events-no-subcommand.trycmd | 5 +++-- tests/integration/_cases/help/help-windows.trycmd | 1 + tests/integration/_cases/help/help.trycmd | 1 + tests/integration/_cases/issues/issues-help.trycmd | 3 +++ 5 files changed, 11 insertions(+), 4 deletions(-) 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) From a25dd3ba91f796a3c00ee8442c1ad7128b273855 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Thu, 15 Jan 2026 11:46:54 +0100 Subject: [PATCH 12/12] docs: add changelog entry for debugging commands --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) 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