Skip to content
Closed
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
228 changes: 226 additions & 2 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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<IssueDetails> {
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<Option<IssueLatestEvent>> {
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<usize>,
sort: Option<&str>,
stats_period: Option<&str>,
) -> ApiResult<Vec<IssueLatestEvent>> {
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<IssueTagValues> {
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<TraceMeta> {
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<Vec<TraceSpan>> {
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<Vec<EventAttachment>> {
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<Vec<u8>> {
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<Vec<Repo>> {
let mut rv = vec![];
Expand Down Expand Up @@ -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}")
}
}

Expand Down Expand Up @@ -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<String>,
pub status: String,
pub level: String,
pub count: String,
pub user_count: u64,
pub first_seen: DateTime<Utc>,
pub last_seen: DateTime<Utc>,
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<String>,
#[serde(alias = "dateCreated")]
pub date_created: Option<String>,
pub tags: Option<Vec<ProcessedEventTag>>,
}

/// 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<IssueTagValue>,
}

/// Individual tag value with count
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct IssueTagValue {
pub value: Option<String>,
pub count: u64,
#[expect(dead_code, reason = "API response field")]
pub first_seen: Option<DateTime<Utc>>,
#[expect(dead_code, reason = "API response field")]
pub last_seen: Option<DateTime<Utc>>,
}

/// Trace metadata summary
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TraceMeta {
pub span_count: Option<u64>,
pub errors: Option<u64>,
pub performance_issues: Option<u64>,
}

/// 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<String>,
pub op: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub duration: Option<f64>,
#[serde(default)]
#[expect(dead_code, reason = "API response field")]
pub is_transaction: bool,
#[serde(default)]
pub children: Vec<TraceSpan>,
#[serde(default)]
pub errors: Vec<serde_json::Value>,
}

/// 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<String>,
#[expect(dead_code, reason = "API response field")]
pub date_created: Option<DateTime<Utc>>,
}

/// Change information for issue bulk updates.
#[derive(Serialize, Default)]
pub struct IssueChanges {
Expand Down
110 changes: 110 additions & 0 deletions src/commands/events/attachment.rs
Original file line number Diff line number Diff line change
@@ -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::<String>("event_id")
.expect("event_id is required");
let attachment_id = matches.get_one::<String>("attachment_id");
let output_path = matches.get_one::<String>("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")
}
}
2 changes: 2 additions & 0 deletions src/commands/events/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
}
Expand Down
Loading