diff --git a/src/api/mod.rs b/src/api/mod.rs index bbba47f0ba..2acf7f0516 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1226,6 +1226,29 @@ impl<'a> AuthenticatedApi<'a> { Ok(rv) } + /// Fetch organization events from the specified dataset + pub fn fetch_organization_events( + &self, + org: &str, + options: &FetchEventsOptions, + ) -> ApiResult> { + let params = options.to_query_params(); + let url = format!( + "/organizations/{}/events/?{}", + PathArg(org), + params.join("&") + ); + + let resp = self.get(&url)?; + + if resp.status() == 404 { + return Err(ApiErrorKind::OrganizationNotFound.into()); + } + + let logs_response: LogsResponse = resp.convert()?; + Ok(logs_response.data) + } + /// List all issues associated with an organization and a project pub fn list_organization_project_issues( &self, @@ -1390,6 +1413,71 @@ impl<'a> AuthenticatedApi<'a> { } } +/// Available datasets for fetching organization events +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Dataset { + /// Our logs dataset + Logs, +} + +impl Dataset { + /// Returns the string representation of the dataset + fn as_str(&self) -> &'static str { + match self { + Dataset::Logs => "logs", + } + } +} + +impl fmt::Display for Dataset { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Options for fetching organization events +pub struct FetchEventsOptions<'a> { + /// Dataset to fetch events from + pub dataset: Dataset, + /// Fields to include in the response + pub fields: &'a [&'a str], + /// Project ID to filter events by + pub project_id: &'a str, + /// Cursor for pagination + pub cursor: Option<&'a str>, + /// Query string to filter events + pub query: &'a str, + /// Number of events per page + pub per_page: usize, + /// Time period for stats + pub stats_period: &'a str, + /// Sort order + pub sort: &'a str, +} + +impl<'a> FetchEventsOptions<'a> { + /// Generate query parameters as a vector of strings + pub fn to_query_params(&self) -> Vec { + let mut params = vec![format!("dataset={}", QueryArg(self.dataset.as_str()))]; + + for field in self.fields { + params.push(format!("field={}", QueryArg(field))); + } + + if let Some(cursor) = self.cursor { + params.push(format!("cursor={}", QueryArg(cursor))); + } + + params.push(format!("project={}", QueryArg(self.project_id))); + params.push(format!("query={}", QueryArg(self.query))); + params.push(format!("per_page={}", self.per_page)); + params.push(format!("statsPeriod={}", QueryArg(self.stats_period))); + params.push(format!("sort={}", QueryArg(self.sort))); + + params + } +} + impl RegionSpecificApi<'_> { fn request(&self, method: Method, url: &str) -> ApiResult { self.api @@ -1609,8 +1697,6 @@ impl ApiRequest { pipeline_env: Option, global_headers: Option>, ) -> ApiResult { - debug!("request {} {}", method, url); - let mut headers = curl::easy::List::new(); headers.append("Expect:").ok(); @@ -1740,7 +1826,6 @@ impl ApiRequest { let body = self.body.as_deref(); let (status, headers) = send_req(&mut self.handle, out, body, self.progress_bar_mode.clone())?; - debug!("response status: {}", status); Ok(ApiResponse { status, headers, @@ -2343,7 +2428,7 @@ pub struct ProcessedEvent { pub tags: Option>, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct ProcessedEventUser { #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, @@ -2377,7 +2462,7 @@ impl fmt::Display for ProcessedEventUser { } } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub struct ProcessedEventTag { pub key: String, pub value: String, @@ -2401,3 +2486,20 @@ pub struct Region { pub struct RegionResponse { pub regions: Vec, } + +/// Response structure for logs API +#[derive(Debug, Deserialize)] +struct LogsResponse { + data: Vec, +} + +/// Log entry structure from the logs API +#[derive(Debug, Deserialize)] +pub struct LogEntry { + #[serde(rename = "sentry.item_id")] + pub item_id: String, + pub trace: Option, + pub severity: Option, + pub timestamp: String, + pub message: Option, +} diff --git a/src/commands/derive_parser.rs b/src/commands/derive_parser.rs index ee6c3a389e..3d81b94733 100644 --- a/src/commands/derive_parser.rs +++ b/src/commands/derive_parser.rs @@ -2,6 +2,7 @@ use crate::utils::auth_token::AuthToken; use crate::utils::value_parsers::{auth_token_parser, kv_parser}; use clap::{command, ArgAction::SetTrue, Parser, Subcommand}; +use super::logs::LogsArgs; use super::send_metric::SendMetricArgs; #[derive(Parser)] @@ -32,5 +33,6 @@ pub(super) struct SentryCLI { #[derive(Subcommand)] pub(super) enum SentryCLICommand { + Logs(LogsArgs), SendMetric(SendMetricArgs), } diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs new file mode 100644 index 0000000000..052983d1aa --- /dev/null +++ b/src/commands/logs/list.rs @@ -0,0 +1,130 @@ +use anyhow::Result; +use clap::Args; + +use crate::api::{Api, Dataset, FetchEventsOptions}; +use crate::config::Config; +use crate::utils::formatting::Table; + +const MAX_ROWS_RANGE: std::ops::RangeInclusive = 1..=1000; +/// Validate that max_rows is in the allowed range +fn validate_max_rows(s: &str) -> Result { + let value = s.parse()?; + if MAX_ROWS_RANGE.contains(&value) { + Ok(value) + } else { + Err(anyhow::anyhow!( + "max-rows must be between {} and {}", + MAX_ROWS_RANGE.start(), + MAX_ROWS_RANGE.end() + )) + } +} + +/// Fields to fetch from the logs API +const LOG_FIELDS: &[&str] = &[ + "sentry.item_id", + "trace", + "severity", + "timestamp", + "message", +]; + +/// Arguments for listing logs +#[derive(Args)] +pub(super) struct ListLogsArgs { + #[arg(short = 'o', long = "org")] + #[arg(help = "The organization ID or slug.")] + org: Option, + + #[arg(short = 'p', long = "project")] + #[arg(help = "The project ID (slug not supported).")] + project: Option, + + #[arg(long = "max-rows", default_value = "100")] + #[arg(value_parser = validate_max_rows)] + #[arg(help = format!("Maximum number of log entries to fetch and display (max {}).", MAX_ROWS_RANGE.end()))] + max_rows: usize, + + #[arg(long = "query", default_value = "")] + #[arg(help = "Query to filter logs. Example: \"level:error\"")] + query: String, +} + +pub(super) fn execute(args: ListLogsArgs) -> Result<()> { + let config = Config::current(); + let (default_org, default_project) = config.get_org_and_project_defaults(); + + let org = args.org.as_ref().or(default_org.as_ref()).ok_or_else(|| { + anyhow::anyhow!( + "No organization specified. Please specify an organization using the --org argument." + ) + })?; + + let project = args + .project + .as_ref() + .or(default_project.as_ref()) + .ok_or_else(|| { + anyhow::anyhow!("No project specified. Use --project or set a default in config.") + })?; + + let api = Api::current(); + + let query = if args.query.is_empty() { + None + } else { + Some(args.query.as_str()) + }; + + execute_single_fetch(&api, org, project, query, LOG_FIELDS, &args) +} + +fn execute_single_fetch( + api: &Api, + org: &str, + project: &str, + query: Option<&str>, + fields: &[&str], + args: &ListLogsArgs, +) -> Result<()> { + let options = FetchEventsOptions { + dataset: Dataset::Logs, + fields, + project_id: project, + cursor: None, + query: query.unwrap_or(""), + per_page: args.max_rows, + stats_period: "90d", + sort: "-timestamp", + }; + + let logs = api + .authenticated()? + .fetch_organization_events(org, &options)?; + + let mut table = Table::new(); + table + .title_row() + .add("Item ID") + .add("Timestamp") + .add("Severity") + .add("Message") + .add("Trace"); + + for log in logs.iter().take(args.max_rows) { + let row = table.add_row(); + row.add(&log.item_id) + .add(&log.timestamp) + .add(log.severity.as_deref().unwrap_or("")) + .add(log.message.as_deref().unwrap_or("")) + .add(log.trace.as_deref().unwrap_or("")); + } + + if table.is_empty() { + println!("No logs found"); + } else { + table.print(); + } + + Ok(()) +} diff --git a/src/commands/logs/mod.rs b/src/commands/logs/mod.rs new file mode 100644 index 0000000000..4d14a9823a --- /dev/null +++ b/src/commands/logs/mod.rs @@ -0,0 +1,41 @@ +mod list; + +use self::list::ListLogsArgs; +use super::derive_parser::{SentryCLI, SentryCLICommand}; +use anyhow::Result; +use clap::ArgMatches; +use clap::{Args, Command, Parser as _, Subcommand}; + +const LIST_ABOUT: &str = "List logs from your organization"; + +#[derive(Args)] +pub(super) struct LogsArgs { + #[command(subcommand)] + subcommand: LogsSubcommand, +} + +#[derive(Subcommand)] +#[command(about = "Manage logs in Sentry")] +#[command(long_about = "Manage and query logs in Sentry. \ + This command provides access to log entries.")] +enum LogsSubcommand { + #[command(about = LIST_ABOUT)] + #[command(long_about = format!("{LIST_ABOUT}. \ + Query and filter log entries from your Sentry projects. \ + Supports filtering by log level and custom queries."))] + List(ListLogsArgs), +} + +pub(super) fn make_command(command: Command) -> Command { + LogsSubcommand::augment_subcommands(command) +} + +pub(super) fn execute(_: &ArgMatches) -> Result<()> { + let SentryCLICommand::Logs(LogsArgs { subcommand }) = SentryCLI::parse().command else { + unreachable!("expected logs subcommand"); + }; + + match subcommand { + LogsSubcommand::List(args) => list::execute(args), + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ecb7f95691..a894619d9f 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -28,6 +28,7 @@ mod files; mod info; mod issues; mod login; +mod logs; mod mobile_app; mod monitors; mod organizations; @@ -57,6 +58,7 @@ macro_rules! each_subcommand { $mac!(info); $mac!(issues); $mac!(login); + $mac!(logs); #[cfg(feature = "unstable-mobile-app")] $mac!(mobile_app); $mac!(monitors); @@ -95,6 +97,7 @@ const UPDATE_NAGGER_CMDS: &[&str] = &[ "info", "issues", "login", + "logs", "organizations", "projects", "releases", diff --git a/src/commands/send_metric/mod.rs b/src/commands/send_metric/mod.rs index c745da47c2..82615b237d 100644 --- a/src/commands/send_metric/mod.rs +++ b/src/commands/send_metric/mod.rs @@ -59,12 +59,10 @@ pub(super) fn make_command(command: Command) -> Command { } pub(super) fn execute(_: &ArgMatches) -> Result<()> { - // When adding a new subcommand to the derive_parser SentryCLI, replace the line below with the following: - // let subcommand = match SentryCLI::parse().command { - // SentryCLICommand::SendMetric(SendMetricArgs { subcommand }) => subcommand, - // _ => panic!("expected send-metric subcommand"), - // }; - let SentryCLICommand::SendMetric(SendMetricArgs { subcommand }) = SentryCLI::parse().command; + let subcommand = match SentryCLI::parse().command { + SentryCLICommand::SendMetric(SendMetricArgs { subcommand }) => subcommand, + _ => unreachable!("expected send-metric subcommand"), + }; log::warn!("{DEPRECATION_MESSAGE}"); diff --git a/tests/integration/_cases/help/help-windows.trycmd b/tests/integration/_cases/help/help-windows.trycmd index b7eea1433f..9f44742a13 100644 --- a/tests/integration/_cases/help/help-windows.trycmd +++ b/tests/integration/_cases/help/help-windows.trycmd @@ -17,6 +17,7 @@ Commands: info Print information about the configuration and verify authentication. issues Manage issues in Sentry. login Authenticate with the Sentry server. + logs Manage logs in Sentry monitors Manage cron monitors on Sentry. organizations Manage organizations on Sentry. projects Manage projects on Sentry. diff --git a/tests/integration/_cases/help/help.trycmd b/tests/integration/_cases/help/help.trycmd index 00a2978dd7..03ac6a02d4 100644 --- a/tests/integration/_cases/help/help.trycmd +++ b/tests/integration/_cases/help/help.trycmd @@ -17,6 +17,7 @@ Commands: info Print information about the configuration and verify authentication. issues Manage issues in Sentry. login Authenticate with the Sentry server. + logs Manage logs in Sentry monitors Manage cron monitors on Sentry. organizations Manage organizations on Sentry. projects Manage projects on Sentry. diff --git a/tests/integration/_cases/logs/logs-help.trycmd b/tests/integration/_cases/logs/logs-help.trycmd new file mode 100644 index 0000000000..04279797ae --- /dev/null +++ b/tests/integration/_cases/logs/logs-help.trycmd @@ -0,0 +1,32 @@ +``` +$ sentry-cli logs --help +? success +Manage and query logs in Sentry. This command provides access to log entries. + +Usage: sentry-cli[EXE] logs [OPTIONS] [COMMAND] + +Commands: + list List logs from your organization + help Print this message or the help of the given subcommand(s) + +Options: + --header + Custom headers that should be attached to all requests + in key:value format. + + --auth-token + Use the given Sentry auth token. + + --log-level + Set the log output verbosity. [possible values: trace, debug, info, warn, error] + + --quiet + Do not print any output while preserving correct exit code. This flag is currently + implemented only for selected subcommands. + + [aliases: silent] + + -h, --help + Print help (see a summary with '-h') + +``` \ No newline at end of file diff --git a/tests/integration/_cases/logs/logs-list-help.trycmd b/tests/integration/_cases/logs/logs-list-help.trycmd new file mode 100644 index 0000000000..d0a9e08ed3 --- /dev/null +++ b/tests/integration/_cases/logs/logs-list-help.trycmd @@ -0,0 +1,45 @@ +``` +$ sentry-cli logs list --help +? success +List logs from your organization. Query and filter log entries from your Sentry projects. Supports +filtering by log level and custom queries. + +Usage: sentry-cli[EXE] logs list [OPTIONS] + +Options: + -o, --org + The organization ID or slug. + + --header + Custom headers that should be attached to all requests + in key:value format. + + -p, --project + The project ID (slug not supported). + + --auth-token + Use the given Sentry auth token. + + --max-rows + Maximum number of log entries to fetch and display (max 1000). + + [default: 100] + + --query + Query to filter logs. Example: "level:error" + + [default: ] + + --log-level + Set the log output verbosity. [possible values: trace, debug, info, warn, error] + + --quiet + Do not print any output while preserving correct exit code. This flag is currently + implemented only for selected subcommands. + + [aliases: silent] + + -h, --help + Print help (see a summary with '-h') + +``` \ No newline at end of file diff --git a/tests/integration/_cases/logs/logs-list-no-logs-found.trycmd b/tests/integration/_cases/logs/logs-list-no-logs-found.trycmd new file mode 100644 index 0000000000..04e607f63c --- /dev/null +++ b/tests/integration/_cases/logs/logs-list-no-logs-found.trycmd @@ -0,0 +1,6 @@ +``` +$ sentry-cli logs list --org wat-org --project 12345 +? success +No logs found + +``` \ No newline at end of file diff --git a/tests/integration/_cases/logs/logs-list-with-data.trycmd b/tests/integration/_cases/logs/logs-list-with-data.trycmd new file mode 100644 index 0000000000..90d26ea33d --- /dev/null +++ b/tests/integration/_cases/logs/logs-list-with-data.trycmd @@ -0,0 +1,12 @@ +``` +$ sentry-cli logs list +? success ++------------------+---------------------------+----------+--------------------------+----------------------+ +| Item ID | Timestamp | Severity | Message | Trace | ++------------------+---------------------------+----------+--------------------------+----------------------+ +| test-item-id-001 | 2025-01-15T10:30:00+00:00 | info | test_log_message_001 | test-trace-id-abc123 | +| test-item-id-002 | 2025-01-15T10:31:00+00:00 | error | test_error_message_002 | test-trace-id-def456 | +| test-item-id-003 | 2025-01-15T10:32:00+00:00 | warning | test_warning_message_003 | test-trace-id-ghi789 | ++------------------+---------------------------+----------+--------------------------+----------------------+ + +``` \ No newline at end of file diff --git a/tests/integration/_cases/logs/logs-list-with-zero-max-rows.trycmd b/tests/integration/_cases/logs/logs-list-with-zero-max-rows.trycmd new file mode 100644 index 0000000000..f188288ff3 --- /dev/null +++ b/tests/integration/_cases/logs/logs-list-with-zero-max-rows.trycmd @@ -0,0 +1,8 @@ +``` +$ sentry-cli logs list --org wat-org --project 12345 --max-rows 0 +? failed +error: invalid value '0' for '--max-rows ': max-rows must be between 1 and 1000 + +For more information, try '--help'. + +``` \ No newline at end of file diff --git a/tests/integration/_responses/logs/get-logs.json b/tests/integration/_responses/logs/get-logs.json new file mode 100644 index 0000000000..b8b4851084 --- /dev/null +++ b/tests/integration/_responses/logs/get-logs.json @@ -0,0 +1,40 @@ +{ + "data": [ + { + "sentry.item_id": "test-item-id-001", + "project.id": 1, + "trace": "test-trace-id-abc123", + "severity_number": 9, + "severity": "info", + "timestamp": "2025-01-15T10:30:00+00:00", + "tags[sentry.timestamp_precise,number]": 1.7051238000000000e+18, + "sentry.observed_timestamp_nanos": "1705123800000000000", + "message": "test_log_message_001", + "key": null + }, + { + "sentry.item_id": "test-item-id-002", + "project.id": 1, + "trace": "test-trace-id-def456", + "severity_number": 13, + "severity": "error", + "timestamp": "2025-01-15T10:31:00+00:00", + "tags[sentry.timestamp_precise,number]": 1.7051238600000000e+18, + "sentry.observed_timestamp_nanos": "1705123860000000000", + "message": "test_error_message_002", + "key": null + }, + { + "sentry.item_id": "test-item-id-003", + "project.id": 1, + "trace": "test-trace-id-ghi789", + "severity_number": 11, + "severity": "warning", + "timestamp": "2025-01-15T10:32:00+00:00", + "tags[sentry.timestamp_precise,number]": 1.7051239200000000e+18, + "sentry.observed_timestamp_nanos": "1705123920000000000", + "message": "test_warning_message_003", + "key": null + } + ] +} \ No newline at end of file diff --git a/tests/integration/logs.rs b/tests/integration/logs.rs new file mode 100644 index 0000000000..ac25ff6c15 --- /dev/null +++ b/tests/integration/logs.rs @@ -0,0 +1,39 @@ +use crate::integration::{MockEndpointBuilder, TestManager}; + +#[test] +fn command_logs_with_api_calls() { + TestManager::new() + .mock_endpoint( + MockEndpointBuilder::new( + "GET", + "/api/0/organizations/wat-org/events/?dataset=logs&field=sentry.item_id&field=trace&field=severity&field=timestamp&field=message&project=wat-project&query=&per_page=100&statsPeriod=90d&sort=-timestamp" + ) + .with_response_file("logs/get-logs.json"), + ) + .register_trycmd_test("logs/logs-list-with-data.trycmd") + .with_default_token(); +} + +#[test] +fn command_logs_no_logs_found() { + TestManager::new() + .mock_endpoint( + MockEndpointBuilder::new( + "GET", + "/api/0/organizations/wat-org/events/?dataset=logs&field=sentry.item_id&field=trace&field=severity&field=timestamp&field=message&project=12345&query=&per_page=100&statsPeriod=90d&sort=-timestamp" + ) + .with_response_body(r#"{"data": []}"#), + ) + .register_trycmd_test("logs/logs-list-no-logs-found.trycmd") + .with_default_token(); +} + +#[test] +fn command_logs_zero_max_rows() { + TestManager::new().register_trycmd_test("logs/logs-list-with-zero-max-rows.trycmd"); +} + +#[test] +fn command_logs_list_help() { + TestManager::new().register_trycmd_test("logs/logs-list-help.trycmd"); +} diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs index ab6e5fba6b..62b68ea816 100644 --- a/tests/integration/mod.rs +++ b/tests/integration/mod.rs @@ -7,6 +7,7 @@ mod info; mod invalid_env; mod issues; mod login; +mod logs; mod mobile_app; mod monitors; mod org_tokens;