From ff53f3e9242afd1a7193a9f98d507cbfb01abce7 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Wed, 30 Jul 2025 11:11:24 +0200 Subject: [PATCH 01/19] feat(logs): Introduce logs command --- src/api/mod.rs | 90 +++++++++++++++- src/commands/derive_parser.rs | 2 + src/commands/logs/common_args.rs | 13 +++ src/commands/logs/list.rs | 100 ++++++++++++++++++ src/commands/logs/mod.rs | 43 ++++++++ src/commands/mod.rs | 3 + src/commands/send_metric/mod.rs | 10 +- .../_cases/help/help-windows.trycmd | 1 + tests/integration/_cases/help/help.trycmd | 1 + .../integration/_cases/logs/logs-help.trycmd | 38 +++++++ .../_cases/logs/logs-list-basic.trycmd | 3 + .../_cases/logs/logs-list-help.trycmd | 43 ++++++++ .../_cases/logs/logs-list-no-defaults.trycmd | 3 + .../_cases/logs/logs-list-with-data.trycmd | 9 ++ .../integration/_responses/logs/get-logs.json | 40 +++++++ tests/integration/logs.rs | 37 +++++++ tests/integration/mod.rs | 1 + 17 files changed, 429 insertions(+), 8 deletions(-) create mode 100644 src/commands/logs/common_args.rs create mode 100644 src/commands/logs/list.rs create mode 100644 src/commands/logs/mod.rs create mode 100644 tests/integration/_cases/logs/logs-help.trycmd create mode 100644 tests/integration/_cases/logs/logs-list-basic.trycmd create mode 100644 tests/integration/_cases/logs/logs-list-help.trycmd create mode 100644 tests/integration/_cases/logs/logs-list-no-defaults.trycmd create mode 100644 tests/integration/_cases/logs/logs-list-with-data.trycmd create mode 100644 tests/integration/_responses/logs/get-logs.json create mode 100644 tests/integration/logs.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index bbba47f0ba..79e341c756 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1225,6 +1225,75 @@ impl<'a> AuthenticatedApi<'a> { Ok(rv) } +} + +/// Options for fetching organization events +#[derive(Debug, Default)] +pub struct FetchEventsOptions<'a> { + /// Project ID to filter events by + pub project_id: Option<&'a str>, + /// Cursor for pagination + pub cursor: Option<&'a str>, + /// Query string to filter events + pub query: Option<&'a str>, + /// Number of events per page (default: 100) + pub per_page: Option, + /// Time period for stats (default: "1h") + pub stats_period: Option<&'a str>, + /// Sort order (default: "-timestamp") + pub sort: Option<&'a str>, +} + +impl<'a> AuthenticatedApi<'a> { + /// Fetch organization events from the specified dataset + pub fn fetch_organization_events( + &self, + org: &str, + dataset: &str, + fields: &[&str], + options: FetchEventsOptions, + ) -> ApiResult> { + let mut params = vec![format!("dataset={}", QueryArg(dataset))]; + + for field in fields { + params.push(format!("field={}", QueryArg(field))); + } + + if let Some(cursor) = options.cursor { + params.push(format!("cursor={}", QueryArg(cursor))); + } + + if let Some(project_id) = options.project_id { + params.push(format!("project={}", QueryArg(project_id))); + } + + if let Some(query) = options.query { + params.push(format!("query={}", QueryArg(query))); + } + + params.push(format!("per_page={}", options.per_page.unwrap_or(100))); + params.push(format!( + "statsPeriod={}", + options.stats_period.unwrap_or("1h") + )); + params.push("referrer=sentry-cli-tail".to_owned()); + params.push(format!("sort={}", options.sort.unwrap_or("-timestamp"))); + + 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( @@ -2343,7 +2412,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 +2446,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 +2470,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/common_args.rs b/src/commands/logs/common_args.rs new file mode 100644 index 0000000000..12a4b697d2 --- /dev/null +++ b/src/commands/logs/common_args.rs @@ -0,0 +1,13 @@ +use clap::Args; + +/// Common arguments for all logs subcommands. +#[derive(Args)] +pub(super) struct CommonLogsArgs { + #[arg(short = 'o', long = "org")] + #[arg(help = "The organization ID or slug.")] + pub(super) org: Option, + + #[arg(short = 'p', long = "project")] + #[arg(help = "The project ID or slug.")] + pub(super) project: Option, +} diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs new file mode 100644 index 0000000000..86014bc0c1 --- /dev/null +++ b/src/commands/logs/list.rs @@ -0,0 +1,100 @@ +use anyhow::Result; +use clap::Args; + +use crate::api::{Api, FetchEventsOptions}; +use crate::config::Config; +use crate::utils::formatting::Table; + +use super::common_args::CommonLogsArgs; + +/// Arguments for listing logs +#[derive(Args)] +pub(super) struct ListLogsArgs { + #[command(flatten)] + pub(super) common: CommonLogsArgs, + + #[arg(long = "max-rows")] + #[arg(help = "Maximum number of rows to print.")] + pub(super) max_rows: Option, + + #[arg(long = "per-page", default_value = "100")] + #[arg(help = "Number of log entries per request (max 1000).")] + pub(super) per_page: usize, + + #[arg(long = "query", default_value = "")] + #[arg(help = "Query to filter logs. Example: \"level:error\"")] + pub(super) query: String, + + #[arg(long = "live")] + #[arg(help = "Live-tail logs (not implemented yet).")] + pub(super) live: bool, +} + +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.common.org.or(default_org).ok_or_else(|| { + anyhow::anyhow!("No organization specified. Use --org or set a default in config.") + })?; + let project = args.common.project.or(default_project).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()) + }; + let fields = [ + "sentry.item_id", + "trace", + "severity", + "timestamp", + "message", + ]; + + let options = FetchEventsOptions { + project_id: Some(&project), + query, + per_page: Some(args.per_page), + stats_period: Some("1h"), + ..Default::default() + }; + + let logs = api + .authenticated()? + .fetch_organization_events(&org, "ourlogs", &fields, options)?; + + let mut table = Table::new(); + table + .title_row() + .add("Item ID") + .add("Timestamp") + .add("Severity") + .add("Message") + .add("Trace"); + + let max_rows = std::cmp::min(logs.len(), args.max_rows.unwrap_or(usize::MAX)); + + if let Some(logs) = logs.get(..max_rows) { + for log in logs { + 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..62aa730545 --- /dev/null +++ b/src/commands/logs/mod.rs @@ -0,0 +1,43 @@ +pub mod common_args; + +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 and supports live-tailing functionality.")] +enum LogsSubcommand { + #[command(about = LIST_ABOUT)] + #[command(long_about = format!("{LIST_ABOUT}. \ +Query and filter log entries from your Sentry projects. \ +Supports filtering by time period, 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..cc2cc0eebd --- /dev/null +++ b/tests/integration/_cases/logs/logs-help.trycmd @@ -0,0 +1,38 @@ +$ sentry-cli logs --help +? success +Manage logs in Sentry. + +Usage: sentry-cli[EXE] logs [OPTIONS] + +Commands: + list List logs from your organization. + help Print this message or the help of the given subcommand(s) + +Options: + -o, --org + The organization ID or slug. + + -p, --project + The project ID or slug. + + --live + Live-tail logs (not implemented yet). + + --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 \ No newline at end of file diff --git a/tests/integration/_cases/logs/logs-list-basic.trycmd b/tests/integration/_cases/logs/logs-list-basic.trycmd new file mode 100644 index 0000000000..537e78f063 --- /dev/null +++ b/tests/integration/_cases/logs/logs-list-basic.trycmd @@ -0,0 +1,3 @@ +$ sentry-cli logs list --org wat-org --project wat-project --max-rows 0 +? success +No logs found \ 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..2083ee073b --- /dev/null +++ b/tests/integration/_cases/logs/logs-list-help.trycmd @@ -0,0 +1,43 @@ +$ sentry-cli logs list --help +? success +List logs from your organization. + +Usage: sentry-cli[EXE] logs list [OPTIONS] + +Options: + --max-rows + Maximum number of rows to print. + + --per-page + Number of log entries per request (max 1000). [default: 100] + + --query + Query to filter logs. Example: "level:error" [default: ] + + -o, --org + The organization ID or slug. + + -p, --project + The project ID or slug. + + --live + Live-tail logs (not implemented yet). + + --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 \ No newline at end of file diff --git a/tests/integration/_cases/logs/logs-list-no-defaults.trycmd b/tests/integration/_cases/logs/logs-list-no-defaults.trycmd new file mode 100644 index 0000000000..91290cb99d --- /dev/null +++ b/tests/integration/_cases/logs/logs-list-no-defaults.trycmd @@ -0,0 +1,3 @@ +$ sentry-cli logs list +? 1 +[ERROR] No organization specified. Use --org or set a default in config. \ 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..b93167383c --- /dev/null +++ b/tests/integration/_cases/logs/logs-list-with-data.trycmd @@ -0,0 +1,9 @@ +$ sentry-cli logs list --org wat-org --project wat-project +? success ++------------------+---------------------+----------+----------------------+---------------------+ +| Item ID | Timestamp | Severity | Message | Trace | ++------------------+---------------------+----------+----------------------+---------------------+ +| test-item-id-001 | 2025-01-15T10:30:00 | info | test_log_message_001 | test-trace-id-abc123 | +| test-item-id-002 | 2025-01-15T10:31:00 | error | test_error_message_002 | test-trace-id-def456 | +| test-item-id-003 | 2025-01-15T10:32:00 | warning | test_warning_message_003 | test-trace-id-ghi789 | ++------------------+---------------------+----------+----------------------+---------------------+ \ 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..3734e3ee22 --- /dev/null +++ b/tests/integration/logs.rs @@ -0,0 +1,37 @@ +use trycmd::TestCases; + +use crate::integration::{MockEndpointBuilder, TestManager}; + +#[test] +fn command_logs_help() { + TestCases::new().case("tests/integration/_cases/logs/logs-help.trycmd"); +} + +#[test] +fn command_logs_list_help() { + TestCases::new().case("tests/integration/_cases/logs/logs-list-help.trycmd"); +} + +#[test] +fn command_logs_list_no_defaults() { + TestCases::new().case("tests/integration/_cases/logs/logs-list-no-defaults.trycmd"); +} + +#[test] +fn command_logs_list_basic() { + TestCases::new().case("tests/integration/_cases/logs/logs-list-basic.trycmd"); +} + +#[test] +fn command_logs_list_with_data() { + TestManager::new() + .mock_endpoint( + MockEndpointBuilder::new( + "GET", + "/api/0/organizations/wat-org/events/?dataset=ourlogs&field=sentry.item_id&field=trace&field=severity&field=timestamp&field=message&project=wat-project&per_page=100&statsPeriod=1h&referrer=sentry-cli-tail&sort=-timestamp", + ) + .with_response_file("logs/get-logs.json"), + ) + .register_trycmd_test("logs/logs-list-with-data.trycmd") + .with_default_token(); +} 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; From 57ca614ed8fa1c47c5353eaf4df88aab6ca72e20 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Wed, 30 Jul 2025 17:45:59 +0200 Subject: [PATCH 02/19] fixes --- src/api/mod.rs | 115 ++++++++++-------- src/commands/logs/common_args.rs | 13 -- src/commands/logs/list.rs | 89 ++++++++------ src/commands/logs/mod.rs | 8 +- .../integration/_cases/logs/logs-help.trycmd | 5 +- .../_cases/logs/logs-list-basic.trycmd | 2 +- .../_cases/logs/logs-list-help.trycmd | 10 +- .../logs-list-with-custom-max-rows.trycmd | 5 + .../_cases/logs/logs-list-with-data.trycmd | 14 +-- .../logs/logs-list-with-zero-max-rows.trycmd | 3 + tests/integration/logs.rs | 42 +++---- 11 files changed, 160 insertions(+), 146 deletions(-) delete mode 100644 src/commands/logs/common_args.rs create mode 100644 tests/integration/_cases/logs/logs-list-with-custom-max-rows.trycmd create mode 100644 tests/integration/_cases/logs/logs-list-with-zero-max-rows.trycmd diff --git a/src/api/mod.rs b/src/api/mod.rs index 79e341c756..095f694974 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1225,60 +1225,14 @@ impl<'a> AuthenticatedApi<'a> { Ok(rv) } -} - -/// Options for fetching organization events -#[derive(Debug, Default)] -pub struct FetchEventsOptions<'a> { - /// Project ID to filter events by - pub project_id: Option<&'a str>, - /// Cursor for pagination - pub cursor: Option<&'a str>, - /// Query string to filter events - pub query: Option<&'a str>, - /// Number of events per page (default: 100) - pub per_page: Option, - /// Time period for stats (default: "1h") - pub stats_period: Option<&'a str>, - /// Sort order (default: "-timestamp") - pub sort: Option<&'a str>, -} -impl<'a> AuthenticatedApi<'a> { /// Fetch organization events from the specified dataset pub fn fetch_organization_events( &self, org: &str, - dataset: &str, - fields: &[&str], - options: FetchEventsOptions, + options: &FetchEventsOptions, ) -> ApiResult> { - let mut params = vec![format!("dataset={}", QueryArg(dataset))]; - - for field in fields { - params.push(format!("field={}", QueryArg(field))); - } - - if let Some(cursor) = options.cursor { - params.push(format!("cursor={}", QueryArg(cursor))); - } - - if let Some(project_id) = options.project_id { - params.push(format!("project={}", QueryArg(project_id))); - } - - if let Some(query) = options.query { - params.push(format!("query={}", QueryArg(query))); - } - - params.push(format!("per_page={}", options.per_page.unwrap_or(100))); - params.push(format!( - "statsPeriod={}", - options.stats_period.unwrap_or("1h") - )); - params.push("referrer=sentry-cli-tail".to_owned()); - params.push(format!("sort={}", options.sort.unwrap_or("-timestamp"))); - + let params = options.to_query_params(); let url = format!( "/organizations/{}/events/?{}", PathArg(org), @@ -1459,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 + OurLogs, +} + +impl Dataset { + /// Returns the string representation of the dataset + pub fn as_str(&self) -> &'static str { + match self { + Dataset::OurLogs => "ourlogs", + } + } +} +/// 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: Option<&'a str>, + /// Cursor for pagination + pub cursor: Option<&'a str>, + /// Query string to filter events + pub query: Option<&'a str>, + /// Number of events per page (default: 100) + pub per_page: Option, + /// Time period for stats (default: "1h") + pub stats_period: Option<&'a str>, + /// Sort order (default: "-timestamp") + pub sort: Option<&'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))); + } + + if let Some(project_id) = self.project_id { + params.push(format!("project={}", QueryArg(project_id))); + } + + if let Some(query) = self.query { + params.push(format!("query={}", QueryArg(query))); + } + + params.push(format!("per_page={}", self.per_page.unwrap_or(100))); + params.push(format!("statsPeriod={}", self.stats_period.unwrap_or("1h"))); + + params.push(format!("sort={}", self.sort.unwrap_or("-timestamp"))); + + params + } +} + impl RegionSpecificApi<'_> { fn request(&self, method: Method, url: &str) -> ApiResult { self.api diff --git a/src/commands/logs/common_args.rs b/src/commands/logs/common_args.rs deleted file mode 100644 index 12a4b697d2..0000000000 --- a/src/commands/logs/common_args.rs +++ /dev/null @@ -1,13 +0,0 @@ -use clap::Args; - -/// Common arguments for all logs subcommands. -#[derive(Args)] -pub(super) struct CommonLogsArgs { - #[arg(short = 'o', long = "org")] - #[arg(help = "The organization ID or slug.")] - pub(super) org: Option, - - #[arg(short = 'p', long = "project")] - #[arg(help = "The project ID or slug.")] - pub(super) project: Option, -} diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index 86014bc0c1..294de1e834 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -1,45 +1,59 @@ use anyhow::Result; use clap::Args; -use crate::api::{Api, FetchEventsOptions}; +use crate::api::{Api, Dataset, FetchEventsOptions}; use crate::config::Config; use crate::utils::formatting::Table; -use super::common_args::CommonLogsArgs; +/// 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 { - #[command(flatten)] - pub(super) common: CommonLogsArgs, + #[arg(short = 'o', long = "org")] + #[arg(help = "The organization ID or slug.")] + org: Option, - #[arg(long = "max-rows")] - #[arg(help = "Maximum number of rows to print.")] - pub(super) max_rows: Option, + #[arg(short = 'p', long = "project")] + #[arg(help = "The project ID (slug not supported).")] + project: Option, - #[arg(long = "per-page", default_value = "100")] - #[arg(help = "Number of log entries per request (max 1000).")] - pub(super) per_page: usize, + #[arg(long = "max-rows", default_value = "100")] + #[arg(help = "Maximum number of log entries to fetch and display (max 1000).")] + max_rows: usize, #[arg(long = "query", default_value = "")] #[arg(help = "Query to filter logs. Example: \"level:error\"")] - pub(super) query: String, - - #[arg(long = "live")] - #[arg(help = "Live-tail logs (not implemented yet).")] - pub(super) live: bool, + 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.common.org.or(default_org).ok_or_else(|| { - anyhow::anyhow!("No organization specified. Use --org or set a default in config.") - })?; - let project = args.common.project.or(default_project).ok_or_else(|| { - anyhow::anyhow!("No project specified. Use --project or set a default in config.") - })?; + let org = args + .org + .as_ref() + .or(default_org.as_ref()) + .ok_or_else(|| { + anyhow::anyhow!("No organization specified. Use --org or set a default in config.") + })? + .to_owned(); + 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.") + })? + .to_owned(); let api = Api::current(); @@ -48,25 +62,32 @@ pub(super) fn execute(args: ListLogsArgs) -> Result<()> { } else { Some(args.query.as_str()) }; - let fields = [ - "sentry.item_id", - "trace", - "severity", - "timestamp", - "message", - ]; + 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 { - project_id: Some(&project), + dataset: Dataset::OurLogs, + fields, + project_id: Some(project), + cursor: None, query, - per_page: Some(args.per_page), + per_page: Some(args.max_rows), stats_period: Some("1h"), - ..Default::default() + sort: Some("-timestamp"), }; let logs = api .authenticated()? - .fetch_organization_events(&org, "ourlogs", &fields, options)?; + .fetch_organization_events(org, &options)?; let mut table = Table::new(); table @@ -77,9 +98,7 @@ pub(super) fn execute(args: ListLogsArgs) -> Result<()> { .add("Message") .add("Trace"); - let max_rows = std::cmp::min(logs.len(), args.max_rows.unwrap_or(usize::MAX)); - - if let Some(logs) = logs.get(..max_rows) { + if let Some(logs) = logs.get(..args.max_rows) { for log in logs { let row = table.add_row(); row.add(&log.item_id) diff --git a/src/commands/logs/mod.rs b/src/commands/logs/mod.rs index 62aa730545..10ad5e0d10 100644 --- a/src/commands/logs/mod.rs +++ b/src/commands/logs/mod.rs @@ -1,5 +1,3 @@ -pub mod common_args; - mod list; use self::list::ListLogsArgs; @@ -19,12 +17,12 @@ pub(super) struct LogsArgs { #[derive(Subcommand)] #[command(about = "Manage logs in Sentry")] #[command(long_about = "Manage and query logs in Sentry. \ -This command provides access to log entries and supports live-tailing functionality.")] + 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 time period, log level, and custom queries."))] + Query and filter log entries from your Sentry projects. \ + Supports filtering by time period, log level, and custom queries."))] List(ListLogsArgs), } diff --git a/tests/integration/_cases/logs/logs-help.trycmd b/tests/integration/_cases/logs/logs-help.trycmd index cc2cc0eebd..7f5d05949d 100644 --- a/tests/integration/_cases/logs/logs-help.trycmd +++ b/tests/integration/_cases/logs/logs-help.trycmd @@ -13,10 +13,7 @@ Options: The organization ID or slug. -p, --project - The project ID or slug. - - --live - Live-tail logs (not implemented yet). + The project ID (slug not supported). --header Custom headers that should be attached to all requests diff --git a/tests/integration/_cases/logs/logs-list-basic.trycmd b/tests/integration/_cases/logs/logs-list-basic.trycmd index 537e78f063..c5a8f2e046 100644 --- a/tests/integration/_cases/logs/logs-list-basic.trycmd +++ b/tests/integration/_cases/logs/logs-list-basic.trycmd @@ -1,3 +1,3 @@ -$ sentry-cli logs list --org wat-org --project wat-project --max-rows 0 +$ sentry-cli logs list --org wat-org --project 12345 --max-rows 0 ? success No logs found \ 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 index 2083ee073b..a73b680dce 100644 --- a/tests/integration/_cases/logs/logs-list-help.trycmd +++ b/tests/integration/_cases/logs/logs-list-help.trycmd @@ -6,10 +6,7 @@ Usage: sentry-cli[EXE] logs list [OPTIONS] Options: --max-rows - Maximum number of rows to print. - - --per-page - Number of log entries per request (max 1000). [default: 100] + Maximum number of log entries to fetch and display (max 1000). [default: 100] --query Query to filter logs. Example: "level:error" [default: ] @@ -18,10 +15,7 @@ Options: The organization ID or slug. -p, --project - The project ID or slug. - - --live - Live-tail logs (not implemented yet). + The project ID (slug not supported). --header Custom headers that should be attached to all requests diff --git a/tests/integration/_cases/logs/logs-list-with-custom-max-rows.trycmd b/tests/integration/_cases/logs/logs-list-with-custom-max-rows.trycmd new file mode 100644 index 0000000000..eb4481295d --- /dev/null +++ b/tests/integration/_cases/logs/logs-list-with-custom-max-rows.trycmd @@ -0,0 +1,5 @@ +$ sentry-cli logs list --org wat-org --project 12345 --max-rows 50 +? success ++------------------+---------------------+----------+----------------------+---------------------+ +| Item ID | Timestamp | Severity | Message | Trace | ++------------------+---------------------+----------+----------------------+---------------------+ \ 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 index b93167383c..c3c8ac159b 100644 --- a/tests/integration/_cases/logs/logs-list-with-data.trycmd +++ b/tests/integration/_cases/logs/logs-list-with-data.trycmd @@ -1,9 +1,9 @@ -$ sentry-cli logs list --org wat-org --project wat-project +$ sentry-cli logs list --org wat-org --project 12345 ? success -+------------------+---------------------+----------+----------------------+---------------------+ -| Item ID | Timestamp | Severity | Message | Trace | -+------------------+---------------------+----------+----------------------+---------------------+ -| test-item-id-001 | 2025-01-15T10:30:00 | info | test_log_message_001 | test-trace-id-abc123 | -| test-item-id-002 | 2025-01-15T10:31:00 | error | test_error_message_002 | test-trace-id-def456 | ++------------------+---------------------+----------+--------------------------+---------------------+ +| Item ID | Timestamp | Severity | Message | Trace | ++------------------+---------------------+----------+--------------------------+---------------------+ +| test-item-id-001 | 2025-01-15T10:30:00 | info | test_log_message_001 | test-trace-id-abc123 | +| test-item-id-002 | 2025-01-15T10:31:00 | error | test_error_message_002 | test-trace-id-def456 | | test-item-id-003 | 2025-01-15T10:32:00 | warning | test_warning_message_003 | test-trace-id-ghi789 | -+------------------+---------------------+----------+----------------------+---------------------+ \ No newline at end of file ++------------------+---------------------+----------+--------------------------+---------------------+ \ 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..c5a8f2e046 --- /dev/null +++ b/tests/integration/_cases/logs/logs-list-with-zero-max-rows.trycmd @@ -0,0 +1,3 @@ +$ sentry-cli logs list --org wat-org --project 12345 --max-rows 0 +? success +No logs found \ No newline at end of file diff --git a/tests/integration/logs.rs b/tests/integration/logs.rs index 3734e3ee22..4a39865f19 100644 --- a/tests/integration/logs.rs +++ b/tests/integration/logs.rs @@ -1,37 +1,29 @@ -use trycmd::TestCases; - use crate::integration::{MockEndpointBuilder, TestManager}; #[test] -fn command_logs_help() { - TestCases::new().case("tests/integration/_cases/logs/logs-help.trycmd"); -} - -#[test] -fn command_logs_list_help() { - TestCases::new().case("tests/integration/_cases/logs/logs-list-help.trycmd"); -} - -#[test] -fn command_logs_list_no_defaults() { - TestCases::new().case("tests/integration/_cases/logs/logs-list-no-defaults.trycmd"); -} - -#[test] -fn command_logs_list_basic() { - TestCases::new().case("tests/integration/_cases/logs/logs-list-basic.trycmd"); -} - -#[test] -fn command_logs_list_with_data() { +fn command_logs_with_api_calls() { TestManager::new() .mock_endpoint( MockEndpointBuilder::new( "GET", - "/api/0/organizations/wat-org/events/?dataset=ourlogs&field=sentry.item_id&field=trace&field=severity&field=timestamp&field=message&project=wat-project&per_page=100&statsPeriod=1h&referrer=sentry-cli-tail&sort=-timestamp", + "/api/0/organizations/wat-org/events/?dataset=ourlogs&field=sentry.item_id&field=trace&field=severity&field=timestamp&field=message&project=12345&per_page=100&statsPeriod=1h&sort=-timestamp", + ) + .with_response_file("logs/get-logs.json"), + ) + .mock_endpoint( + MockEndpointBuilder::new( + "GET", + "/api/0/organizations/wat-org/events/?dataset=ourlogs&field=sentry.item_id&field=trace&field=severity&field=timestamp&field=message&project=12345&per_page=50&statsPeriod=1h&sort=-timestamp", ) .with_response_file("logs/get-logs.json"), ) - .register_trycmd_test("logs/logs-list-with-data.trycmd") + .mock_endpoint( + MockEndpointBuilder::new( + "GET", + "/api/0/organizations/wat-org/events/?dataset=ourlogs&field=sentry.item_id&field=trace&field=severity&field=timestamp&field=message&project=12345&per_page=0&statsPeriod=1h&sort=-timestamp", + ) + .with_response_body("{\"data\": []}"), + ) + .register_trycmd_test("logs/*.trycmd") .with_default_token(); } From 2ea949b65104096a30e5cf36a993489b2d7c57a6 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Fri, 1 Aug 2025 11:48:52 +0200 Subject: [PATCH 03/19] fixes x2 --- src/api/mod.rs | 9 +++- src/commands/logs/list.rs | 32 +++++++++---- .../_cases/logs/logs-help-windows.trycmd | 32 +++++++++++++ .../integration/_cases/logs/logs-help.trycmd | 17 +++---- .../_cases/logs/logs-list-basic.trycmd | 7 ++- .../_cases/logs/logs-list-help.trycmd | 30 +++++++----- .../_cases/logs/logs-list-no-defaults.trycmd | 3 -- .../logs-list-with-custom-max-rows.trycmd | 5 -- .../_cases/logs/logs-list-with-data.trycmd | 19 ++++---- .../logs/logs-list-with-zero-max-rows.trycmd | 9 +++- tests/integration/logs.rs | 46 +++++++++++++------ 11 files changed, 144 insertions(+), 65 deletions(-) create mode 100644 tests/integration/_cases/logs/logs-help-windows.trycmd delete mode 100644 tests/integration/_cases/logs/logs-list-no-defaults.trycmd delete mode 100644 tests/integration/_cases/logs/logs-list-with-custom-max-rows.trycmd diff --git a/src/api/mod.rs b/src/api/mod.rs index 095f694974..5b83769532 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1422,12 +1422,19 @@ pub enum Dataset { impl Dataset { /// Returns the string representation of the dataset - pub fn as_str(&self) -> &'static str { + fn as_str(&self) -> &'static str { match self { Dataset::OurLogs => "ourlogs", } } } + +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 diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index 294de1e834..c11de2e2a8 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -5,6 +5,18 @@ use crate::api::{Api, Dataset, FetchEventsOptions}; use crate::config::Config; use crate::utils::formatting::Table; +/// Validate that max_rows is greater than 0 +fn validate_max_rows(s: &str) -> Result { + let value = s + .parse::() + .map_err(|_| "invalid number".to_owned())?; + if value == 0 { + Err("max-rows must be greater than 0".to_owned()) + } else { + Ok(value) + } +} + /// Fields to fetch from the logs API const LOG_FIELDS: &[&str] = &[ "sentry.item_id", @@ -26,6 +38,7 @@ pub(super) struct ListLogsArgs { project: Option, #[arg(long = "max-rows", default_value = "100")] + #[arg(value_parser = validate_max_rows)] #[arg(help = "Maximum number of log entries to fetch and display (max 1000).")] max_rows: usize, @@ -43,7 +56,7 @@ pub(super) fn execute(args: ListLogsArgs) -> Result<()> { .as_ref() .or(default_org.as_ref()) .ok_or_else(|| { - anyhow::anyhow!("No organization specified. Use --org or set a default in config.") + anyhow::anyhow!("No organization specified. Please specify an organization using the --org argument.") })? .to_owned(); let project = args @@ -98,15 +111,14 @@ fn execute_single_fetch( .add("Message") .add("Trace"); - if let Some(logs) = logs.get(..args.max_rows) { - for log in logs { - 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("")); - } + let logs_to_show = &logs[..args.max_rows.min(logs.len())]; + for log in logs_to_show { + 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() { diff --git a/tests/integration/_cases/logs/logs-help-windows.trycmd b/tests/integration/_cases/logs/logs-help-windows.trycmd new file mode 100644 index 0000000000..04279797ae --- /dev/null +++ b/tests/integration/_cases/logs/logs-help-windows.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-help.trycmd b/tests/integration/_cases/logs/logs-help.trycmd index 7f5d05949d..43d5b16c2a 100644 --- a/tests/integration/_cases/logs/logs-help.trycmd +++ b/tests/integration/_cases/logs/logs-help.trycmd @@ -1,20 +1,15 @@ +``` $ sentry-cli logs --help ? success -Manage logs in Sentry. +Manage and query logs in Sentry. This command provides access to log entries. -Usage: sentry-cli[EXE] logs [OPTIONS] +Usage: sentry-cli logs [OPTIONS] [COMMAND] Commands: - list List logs from your organization. + list List logs from your organization help Print this message or the help of the given subcommand(s) Options: - -o, --org - The organization ID or slug. - - -p, --project - The project ID (slug not supported). - --header Custom headers that should be attached to all requests in key:value format. @@ -32,4 +27,6 @@ Options: [aliases: silent] -h, --help - Print help \ No newline at end of file + Print help (see a summary with '-h') + +``` \ No newline at end of file diff --git a/tests/integration/_cases/logs/logs-list-basic.trycmd b/tests/integration/_cases/logs/logs-list-basic.trycmd index c5a8f2e046..da6f07b998 100644 --- a/tests/integration/_cases/logs/logs-list-basic.trycmd +++ b/tests/integration/_cases/logs/logs-list-basic.trycmd @@ -1,3 +1,6 @@ -$ sentry-cli logs list --org wat-org --project 12345 --max-rows 0 +``` +$ sentry-cli logs list --org wat-org --project 12345 --max-rows 1 ? success -No logs found \ No newline at end of file +No logs found + +``` \ 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 index a73b680dce..e1824925d5 100644 --- a/tests/integration/_cases/logs/logs-list-help.trycmd +++ b/tests/integration/_cases/logs/logs-list-help.trycmd @@ -1,29 +1,35 @@ +``` $ sentry-cli logs list --help ? success -List logs from your organization. +List logs from your organization. Query and filter log entries from your Sentry projects. Supports +filtering by time period, log level, and custom queries. Usage: sentry-cli[EXE] logs list [OPTIONS] Options: - --max-rows - Maximum number of log entries to fetch and display (max 1000). [default: 100] - - --query - Query to filter logs. Example: "level:error" [default: ] - -o, --org The organization ID or slug. - -p, --project - The project ID (slug not supported). - --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] @@ -34,4 +40,6 @@ Options: [aliases: silent] -h, --help - Print help \ No newline at end of file + Print help (see a summary with '-h') + +``` \ No newline at end of file diff --git a/tests/integration/_cases/logs/logs-list-no-defaults.trycmd b/tests/integration/_cases/logs/logs-list-no-defaults.trycmd deleted file mode 100644 index 91290cb99d..0000000000 --- a/tests/integration/_cases/logs/logs-list-no-defaults.trycmd +++ /dev/null @@ -1,3 +0,0 @@ -$ sentry-cli logs list -? 1 -[ERROR] No organization specified. Use --org or set a default in config. \ No newline at end of file diff --git a/tests/integration/_cases/logs/logs-list-with-custom-max-rows.trycmd b/tests/integration/_cases/logs/logs-list-with-custom-max-rows.trycmd deleted file mode 100644 index eb4481295d..0000000000 --- a/tests/integration/_cases/logs/logs-list-with-custom-max-rows.trycmd +++ /dev/null @@ -1,5 +0,0 @@ -$ sentry-cli logs list --org wat-org --project 12345 --max-rows 50 -? success -+------------------+---------------------+----------+----------------------+---------------------+ -| Item ID | Timestamp | Severity | Message | Trace | -+------------------+---------------------+----------+----------------------+---------------------+ \ 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 index c3c8ac159b..90d26ea33d 100644 --- a/tests/integration/_cases/logs/logs-list-with-data.trycmd +++ b/tests/integration/_cases/logs/logs-list-with-data.trycmd @@ -1,9 +1,12 @@ -$ sentry-cli logs list --org wat-org --project 12345 +``` +$ sentry-cli logs list ? success -+------------------+---------------------+----------+--------------------------+---------------------+ -| Item ID | Timestamp | Severity | Message | Trace | -+------------------+---------------------+----------+--------------------------+---------------------+ -| test-item-id-001 | 2025-01-15T10:30:00 | info | test_log_message_001 | test-trace-id-abc123 | -| test-item-id-002 | 2025-01-15T10:31:00 | error | test_error_message_002 | test-trace-id-def456 | -| test-item-id-003 | 2025-01-15T10:32:00 | warning | test_warning_message_003 | test-trace-id-ghi789 | -+------------------+---------------------+----------+--------------------------+---------------------+ \ No newline at end of file ++------------------+---------------------------+----------+--------------------------+----------------------+ +| 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 index c5a8f2e046..74199157df 100644 --- a/tests/integration/_cases/logs/logs-list-with-zero-max-rows.trycmd +++ b/tests/integration/_cases/logs/logs-list-with-zero-max-rows.trycmd @@ -1,3 +1,8 @@ +``` $ sentry-cli logs list --org wat-org --project 12345 --max-rows 0 -? success -No logs found \ No newline at end of file +? failed +error: invalid value '0' for '--max-rows ': max-rows must be greater than 0 + +For more information, try '--help'. + +``` \ No newline at end of file diff --git a/tests/integration/logs.rs b/tests/integration/logs.rs index 4a39865f19..9d5be570b4 100644 --- a/tests/integration/logs.rs +++ b/tests/integration/logs.rs @@ -5,25 +5,45 @@ fn command_logs_with_api_calls() { TestManager::new() .mock_endpoint( MockEndpointBuilder::new( - "GET", - "/api/0/organizations/wat-org/events/?dataset=ourlogs&field=sentry.item_id&field=trace&field=severity&field=timestamp&field=message&project=12345&per_page=100&statsPeriod=1h&sort=-timestamp", - ) - .with_response_file("logs/get-logs.json"), - ) - .mock_endpoint( - MockEndpointBuilder::new( - "GET", - "/api/0/organizations/wat-org/events/?dataset=ourlogs&field=sentry.item_id&field=trace&field=severity&field=timestamp&field=message&project=12345&per_page=50&statsPeriod=1h&sort=-timestamp", + "GET", + "/api/0/organizations/wat-org/events/?dataset=ourlogs&field=sentry.item_id&field=trace&field=severity&field=timestamp&field=message&project=wat-project&per_page=100&statsPeriod=1h&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_basic() { + TestManager::new() .mock_endpoint( MockEndpointBuilder::new( - "GET", - "/api/0/organizations/wat-org/events/?dataset=ourlogs&field=sentry.item_id&field=trace&field=severity&field=timestamp&field=message&project=12345&per_page=0&statsPeriod=1h&sort=-timestamp", + "GET", + "/api/0/organizations/wat-org/events/?dataset=ourlogs&field=sentry.item_id&field=trace&field=severity&field=timestamp&field=message&project=12345&per_page=1&statsPeriod=1h&sort=-timestamp" ) - .with_response_body("{\"data\": []}"), + .with_response_body(r#"{"data": []}"#), ) - .register_trycmd_test("logs/*.trycmd") + .register_trycmd_test("logs/logs-list-basic.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_help() { + let manager = TestManager::new(); + + #[cfg(not(windows))] + manager.register_trycmd_test("logs/logs-help.trycmd"); + #[cfg(windows)] + manager.register_trycmd_test("logs/logs-help-windows.trycmd"); +} + +#[test] +fn command_logs_list_help() { + TestManager::new().register_trycmd_test("logs/logs-list-help.trycmd"); +} From d18413f4f259b2019d8133fdf7fafa6ff239b82e Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Thu, 31 Jul 2025 10:31:33 +0200 Subject: [PATCH 04/19] feat(logs): support log streaming Signed-off-by: Vjeran Grozdanic --- src/api/mod.rs | 2 +- src/commands/logs/list.rs | 271 +++++++++++++++++- src/utils/formatting.rs | 69 +++++ .../_cases/logs/logs-list-help.trycmd | 8 + 4 files changed, 347 insertions(+), 3 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 5b83769532..cc24c4b41e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2504,7 +2504,7 @@ struct LogsResponse { } /// Log entry structure from the logs API -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct LogEntry { #[serde(rename = "sentry.item_id")] pub item_id: String, diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index c11de2e2a8..00ff2ead13 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -1,7 +1,9 @@ use anyhow::Result; use clap::Args; +use std::collections::HashSet; +use std::time::Duration; -use crate::api::{Api, Dataset, FetchEventsOptions}; +use crate::api::{Api, Dataset, FetchEventsOptions, LogEntry}; use crate::config::Config; use crate::utils::formatting::Table; @@ -26,6 +28,9 @@ const LOG_FIELDS: &[&str] = &[ "message", ]; +/// Maximum number of log entries to keep in memory for deduplication +const MAX_DEDUP_BUFFER_SIZE: usize = 10_000; + /// Arguments for listing logs #[derive(Args)] pub(super) struct ListLogsArgs { @@ -45,6 +50,14 @@ pub(super) struct ListLogsArgs { #[arg(long = "query", default_value = "")] #[arg(help = "Query to filter logs. Example: \"level:error\"")] query: String, + + #[arg(long = "live")] + #[arg(help = "Enable live streaming mode to continuously poll for new logs.")] + live: bool, + + #[arg(long = "poll-interval", default_value = "2")] + #[arg(help = "Polling interval in seconds for live streaming mode.")] + poll_interval: u64, } pub(super) fn execute(args: ListLogsArgs) -> Result<()> { @@ -76,7 +89,11 @@ pub(super) fn execute(args: ListLogsArgs) -> Result<()> { Some(args.query.as_str()) }; - execute_single_fetch(&api, &org, &project, query, LOG_FIELDS, &args) + if args.live { + execute_live_streaming(&api, &org, &project, query, LOG_FIELDS, &args) + } else { + execute_single_fetch(&api, &org, &project, query, LOG_FIELDS, &args) + } } fn execute_single_fetch( @@ -129,3 +146,253 @@ fn execute_single_fetch( Ok(()) } + +/// Manages deduplication of log entries with a bounded buffer +struct LogDeduplicator { + /// Set of seen log IDs for quick lookup + seen_ids: HashSet, + /// Buffer of log entries in order (for maintaining size limit) + buffer: Vec, + /// Maximum size of the buffer + max_size: usize, +} + +impl LogDeduplicator { + fn new(max_size: usize) -> Self { + Self { + seen_ids: HashSet::new(), + buffer: Vec::new(), + max_size, + } + } + + /// Add new logs and return only the ones that haven't been seen before + fn add_logs(&mut self, new_logs: Vec) -> Vec { + let mut unique_logs = Vec::new(); + + for log in new_logs { + if !self.seen_ids.contains(&log.item_id) { + self.seen_ids.insert(log.item_id.clone()); + self.buffer.push(log.clone()); + unique_logs.push(log); + } + } + + // Maintain buffer size limit by removing oldest entries + while self.buffer.len() > self.max_size { + let removed_log = self.buffer.remove(0); + self.seen_ids.remove(&removed_log.item_id); + } + + unique_logs + } +} + +fn execute_live_streaming( + api: &Api, + org: &str, + project: &str, + query: Option<&str>, + fields: &[&str], + args: &ListLogsArgs, +) -> Result<()> { + let mut deduplicator = LogDeduplicator::new(MAX_DEDUP_BUFFER_SIZE); + let poll_duration = Duration::from_secs(args.poll_interval); + let mut consecutive_new_only_count = 0; + const WARNING_THRESHOLD: usize = 3; // Show warning after 3 consecutive new-only responses + + println!("Starting live log streaming..."); + println!( + "Polling every {} seconds. Press Ctrl+C to stop.", + args.poll_interval + ); + + // Set up table with headers and print header once + let mut table = Table::new(); + table + .title_row() + .add("Item ID") + .add("Timestamp") + .add("Severity") + .add("Message") + .add("Trace"); + + let mut header_printed = false; + + loop { + let options = FetchEventsOptions { + dataset: Dataset::OurLogs, + fields, + project_id: Some(project), + cursor: None, + query, + per_page: Some(args.max_rows), + stats_period: Some("1h"), + sort: Some("-timestamp"), + }; + + match api + .authenticated()? + .fetch_organization_events(org, &options) + { + Ok(logs) => { + let unique_logs = deduplicator.add_logs(logs); + + if unique_logs.is_empty() { + consecutive_new_only_count += 1; + + if consecutive_new_only_count >= WARNING_THRESHOLD && args.query.is_empty() { + eprintln!( + "\n⚠️ Warning: No new logs found for {consecutive_new_only_count} consecutive polls." + ); + + // Reset counter to avoid spam + consecutive_new_only_count = 0; + } + } else { + consecutive_new_only_count = 0; + + // Add new logs to table + for log in unique_logs { + 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 !header_printed { + // Print header with first data batch so column widths match actual data + table.print_table_start(); + header_printed = true; + } else { + // Print only the rows (without table borders) for subsequent batches + table.print_rows_only(); + } + // Clear rows to free memory but keep the table structure for reuse + table.clear_rows(); + } + } + Err(e) => { + eprintln!("Error fetching logs: {e}"); + } + } + + std::thread::sleep(poll_duration); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_log(id: &str, message: &str) -> LogEntry { + LogEntry { + item_id: id.to_owned(), + trace: None, + severity: Some("info".to_owned()), + timestamp: "2025-01-01T00:00:00Z".to_owned(), + message: Some(message.to_owned()), + } + } + + #[test] + fn test_log_deduplicator_new() { + let deduplicator = LogDeduplicator::new(100); + assert_eq!(deduplicator.seen_ids.len(), 0); + } + + #[test] + fn test_log_deduplicator_add_unique_logs() { + let mut deduplicator = LogDeduplicator::new(10); + + let log1 = create_test_log("1", "test message 1"); + let log2 = create_test_log("2", "test message 2"); + + let unique_logs = deduplicator.add_logs(vec![log1.clone(), log2.clone()]); + + assert_eq!(unique_logs.len(), 2); + assert_eq!(deduplicator.seen_ids.len(), 2); + } + + #[test] + fn test_log_deduplicator_deduplicate_logs() { + let mut deduplicator = LogDeduplicator::new(10); + + let log1 = create_test_log("1", "test message 1"); + let log2 = create_test_log("2", "test message 2"); + + // Add logs first time + let unique_logs1 = deduplicator.add_logs(vec![log1.clone(), log2.clone()]); + assert_eq!(unique_logs1.len(), 2); + + // Add same logs again + let unique_logs2 = deduplicator.add_logs(vec![log1.clone(), log2.clone()]); + assert_eq!(unique_logs2.len(), 0); // Should be empty as logs already seen + + assert_eq!(deduplicator.seen_ids.len(), 2); + } + + #[test] + fn test_log_deduplicator_buffer_size_limit() { + let mut deduplicator = LogDeduplicator::new(3); + + // Add 5 logs to a buffer with max size 3 + let logs = vec![ + create_test_log("1", "test message 1"), + create_test_log("2", "test message 2"), + create_test_log("3", "test message 3"), + create_test_log("4", "test message 4"), + create_test_log("5", "test message 5"), + ]; + + let unique_logs = deduplicator.add_logs(logs); + assert_eq!(unique_logs.len(), 5); + + // After adding 5 logs to a buffer with max size 3, the oldest 2 should be evicted + // So logs 1 and 2 should no longer be in the seen_ids set + // Adding them again should return them as new logs + let duplicate_logs = vec![ + create_test_log("1", "test message 1"), + create_test_log("2", "test message 2"), + ]; + let duplicate_unique_logs = deduplicator.add_logs(duplicate_logs); + assert_eq!(duplicate_unique_logs.len(), 2); + + // Test that adding new logs still works + let new_logs = vec![create_test_log("6", "test message 6")]; + let new_unique_logs = deduplicator.add_logs(new_logs); + assert_eq!(new_unique_logs.len(), 1); + } + + #[test] + fn test_log_deduplicator_mixed_new_and_old_logs() { + let mut deduplicator = LogDeduplicator::new(10); + + // Add initial logs + let initial_logs = vec![ + create_test_log("1", "test message 1"), + create_test_log("2", "test message 2"), + ]; + let unique_logs1 = deduplicator.add_logs(initial_logs); + assert_eq!(unique_logs1.len(), 2); + + // Add mix of new and old logs + let mixed_logs = vec![ + create_test_log("1", "test message 1"), // old + create_test_log("3", "test message 3"), // new + create_test_log("2", "test message 2"), // old + create_test_log("4", "test message 4"), // new + ]; + let unique_logs2 = deduplicator.add_logs(mixed_logs); + + // Should only return the new logs (3 and 4) + assert_eq!(unique_logs2.len(), 2); + assert_eq!(unique_logs2[0].item_id, "3"); + assert_eq!(unique_logs2[1].item_id, "4"); + + assert_eq!(deduplicator.seen_ids.len(), 4); + assert_eq!(deduplicator.buffer.len(), 4); + } +} diff --git a/src/utils/formatting.rs b/src/utils/formatting.rs index d7ac855960..76939bf989 100644 --- a/src/utils/formatting.rs +++ b/src/utils/formatting.rs @@ -98,6 +98,75 @@ impl Table { } tbl.print_tty(false).ok(); } + + /// Print only the header row for streaming mode + pub fn print_header(&self) { + if let Some(ref title_row) = self.title_row { + let mut tbl = prettytable::Table::new(); + tbl.set_format(*prettytable::format::consts::FORMAT_NO_BORDER); + tbl.add_row(title_row.make_row()); + tbl.print_tty(false).ok(); + } + } + + /// Print header with first data batch for streaming mode + /// This ensures header column widths match the actual data by letting prettytable + /// size columns based on both header and data content together + pub fn print_table_start(&self) { + if self.is_empty() { + self.print_header(); + return; + } + + let mut tbl = prettytable::Table::new(); + // Use the same base format as print_rows_only but add header separator + let mut format = *prettytable::format::consts::FORMAT_NO_BORDER; + format.column_separator('|'); + format.padding(1, 1); + format.borders('|'); // Add left and right borders + // Add separator line under the header + format.separator( + prettytable::format::LinePosition::Title, + prettytable::format::LineSeparator::new('-', '+', '+', '+'), + ); + tbl.set_format(format); + + if let Some(ref title_row) = self.title_row { + tbl.set_titles(title_row.make_row()); + } + + for row in &self.rows { + tbl.add_row(row.make_row()); + } + + tbl.print_tty(false).ok(); + } + + /// Print only the current rows with column separators but no borders for streaming mode + pub fn print_rows_only(&self) { + if self.is_empty() { + return; + } + + let mut tbl = prettytable::Table::new(); + // Use format with column separators (|) and side borders, but no top/bottom borders + let mut format = *prettytable::format::consts::FORMAT_NO_BORDER; + format.column_separator('|'); + format.padding(1, 1); + format.borders('|'); // Add left and right borders + tbl.set_format(format); + + for row in &self.rows { + tbl.add_row(row.make_row()); + } + + tbl.print_tty(false).ok(); + } + + /// Clear all rows but keep the header for reuse in streaming mode + pub fn clear_rows(&mut self) { + self.rows.clear(); + } } impl Default for Table { diff --git a/tests/integration/_cases/logs/logs-list-help.trycmd b/tests/integration/_cases/logs/logs-list-help.trycmd index e1824925d5..66e7916016 100644 --- a/tests/integration/_cases/logs/logs-list-help.trycmd +++ b/tests/integration/_cases/logs/logs-list-help.trycmd @@ -30,9 +30,17 @@ Options: [default: ] + --live + Enable live streaming mode to continuously poll for new logs. + --log-level Set the log output verbosity. [possible values: trace, debug, info, warn, error] + --poll-interval + Polling interval in seconds for live streaming mode. + + [default: 2] + --quiet Do not print any output while preserving correct exit code. This flag is currently implemented only for selected subcommands. From 7827e2fd7513ecf7821e7b5fed88aae808e54ad7 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Tue, 12 Aug 2025 11:25:02 +0200 Subject: [PATCH 05/19] fixes --- src/api/mod.rs | 2 +- src/commands/logs/list.rs | 98 ++++++++++++------- src/utils/formatting.rs | 22 ++--- .../_cases/logs/logs-list-help.trycmd | 6 +- 4 files changed, 70 insertions(+), 58 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 41ac6f822c..bc8ac2b0d7 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2519,7 +2519,7 @@ struct LogsResponse { } /// Log entry structure from the logs API -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct LogEntry { #[serde(rename = "sentry.item_id")] pub item_id: String, diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index 59c257202a..4c04bbdf46 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -1,9 +1,11 @@ use std::borrow::Cow; +use std::collections::HashSet; +use std::time::Duration; use anyhow::Result; use clap::Args; -use crate::api::{Api, Dataset, FetchEventsOptions}; +use crate::api::{Api, Dataset, FetchEventsOptions, LogEntry}; use crate::config::Config; use crate::utils::formatting::Table; @@ -55,6 +57,14 @@ pub(super) struct ListLogsArgs { #[arg(long = "query", default_value = "")] #[arg(help = "Query to filter logs. Example: \"level:error\"")] query: String, + + #[arg(long = "live")] + #[arg(help = "Live stream logs.")] + live: bool, + + #[arg(long = "poll-interval", default_value = "2")] + #[arg(help = "Poll interval in seconds. Only used when --live is specified.")] + poll_interval: u64, } pub(super) fn execute(args: ListLogsArgs) -> Result<()> { @@ -90,7 +100,11 @@ pub(super) fn execute(args: ListLogsArgs) -> Result<()> { (Cow::Owned(query), None) }; - execute_single_fetch(&api, org, project_id, &query, LOG_FIELDS, &args) + if args.live { + execute_live_streaming(&api, org, project_id, &query, LOG_FIELDS, &args) + } else { + execute_single_fetch(&api, org, project_id, &query, LOG_FIELDS, &args) + } } fn execute_single_fetch( @@ -153,6 +167,8 @@ struct LogDeduplicator { max_size: usize, } +const MAX_DEDUP_BUFFER_SIZE: usize = 10_000; + impl LogDeduplicator { fn new(max_size: usize) -> Self { Self { @@ -187,15 +203,15 @@ impl LogDeduplicator { fn execute_live_streaming( api: &Api, org: &str, - project: &str, - query: Option<&str>, + project: Option<&str>, + query: &str, fields: &[&str], args: &ListLogsArgs, ) -> Result<()> { let mut deduplicator = LogDeduplicator::new(MAX_DEDUP_BUFFER_SIZE); let poll_duration = Duration::from_secs(args.poll_interval); let mut consecutive_new_only_count = 0; - const WARNING_THRESHOLD: usize = 3; // Show warning after 3 consecutive new-only responses + const WARNING_THRESHOLD: usize = 3; // Show message every 3 consecutive empty polls println!("Starting live log streaming..."); println!( @@ -217,14 +233,14 @@ fn execute_live_streaming( loop { let options = FetchEventsOptions { - dataset: Dataset::OurLogs, + dataset: Dataset::Logs, fields, - project_id: Some(project), + project_id: project, cursor: None, query, - per_page: Some(args.max_rows), - stats_period: Some("1h"), - sort: Some("-timestamp"), + per_page: args.max_rows, + stats_period: "10m", + sort: "-timestamp", }; match api @@ -237,12 +253,17 @@ fn execute_live_streaming( if unique_logs.is_empty() { consecutive_new_only_count += 1; - if consecutive_new_only_count >= WARNING_THRESHOLD && args.query.is_empty() { - eprintln!( - "\n⚠️ Warning: No new logs found for {consecutive_new_only_count} consecutive polls." - ); - - // Reset counter to avoid spam + if consecutive_new_only_count >= WARNING_THRESHOLD { + if args.query.trim().is_empty() { + eprintln!("\nNo logs found in the last {WARNING_THRESHOLD} polls."); + } else { + eprintln!( + "\nNo logs found in the last {WARNING_THRESHOLD} polls. Consider adjusting your query filter: \"{}\"", + args.query + ); + } + + // Reset counter to show again after the next threshold consecutive_new_only_count = 0; } } else { @@ -390,30 +411,31 @@ mod tests { assert_eq!(deduplicator.seen_ids.len(), 4); assert_eq!(deduplicator.buffer.len(), 4); - #[test] - fn test_is_numeric_project_id_purely_numeric() { - assert!(is_numeric_project_id("123456")); - assert!(is_numeric_project_id("1")); - assert!(is_numeric_project_id("999999999")); - } + } - #[test] - fn test_is_numeric_project_id_alphanumeric() { - assert!(!is_numeric_project_id("abc123")); - assert!(!is_numeric_project_id("123abc")); - assert!(!is_numeric_project_id("my-project")); - } + #[test] + fn test_is_numeric_project_id_purely_numeric() { + assert!(is_numeric_project_id("123456")); + assert!(is_numeric_project_id("1")); + assert!(is_numeric_project_id("999999999")); + } - #[test] - fn test_is_numeric_project_id_numeric_with_dash() { - assert!(!is_numeric_project_id("123-45")); - assert!(!is_numeric_project_id("1-2-3")); - assert!(!is_numeric_project_id("999-888")); - } + #[test] + fn test_is_numeric_project_id_alphanumeric() { + assert!(!is_numeric_project_id("abc123")); + assert!(!is_numeric_project_id("123abc")); + assert!(!is_numeric_project_id("my-project")); + } - #[test] - fn test_is_numeric_project_id_empty_string() { - assert!(!is_numeric_project_id("")); - } + #[test] + fn test_is_numeric_project_id_numeric_with_dash() { + assert!(!is_numeric_project_id("123-45")); + assert!(!is_numeric_project_id("1-2-3")); + assert!(!is_numeric_project_id("999-888")); + } + + #[test] + fn test_is_numeric_project_id_empty_string() { + assert!(!is_numeric_project_id("")); } } diff --git a/src/utils/formatting.rs b/src/utils/formatting.rs index 76939bf989..69b7e21617 100644 --- a/src/utils/formatting.rs +++ b/src/utils/formatting.rs @@ -99,32 +99,22 @@ impl Table { tbl.print_tty(false).ok(); } - /// Print only the header row for streaming mode - pub fn print_header(&self) { - if let Some(ref title_row) = self.title_row { - let mut tbl = prettytable::Table::new(); - tbl.set_format(*prettytable::format::consts::FORMAT_NO_BORDER); - tbl.add_row(title_row.make_row()); - tbl.print_tty(false).ok(); - } - } - /// Print header with first data batch for streaming mode /// This ensures header column widths match the actual data by letting prettytable /// size columns based on both header and data content together pub fn print_table_start(&self) { - if self.is_empty() { - self.print_header(); - return; - } - let mut tbl = prettytable::Table::new(); // Use the same base format as print_rows_only but add header separator let mut format = *prettytable::format::consts::FORMAT_NO_BORDER; format.column_separator('|'); format.padding(1, 1); format.borders('|'); // Add left and right borders - // Add separator line under the header + // Add top border above the header + format.separator( + prettytable::format::LinePosition::Top, + prettytable::format::LineSeparator::new('-', '+', '+', '+'), + ); + // Add separator line under the header format.separator( prettytable::format::LinePosition::Title, prettytable::format::LineSeparator::new('-', '+', '+', '+'), diff --git a/tests/integration/_cases/logs/logs-list-help.trycmd b/tests/integration/_cases/logs/logs-list-help.trycmd index 52b8e8b343..6811658b7c 100644 --- a/tests/integration/_cases/logs/logs-list-help.trycmd +++ b/tests/integration/_cases/logs/logs-list-help.trycmd @@ -34,13 +34,13 @@ Options: [default: ] --live - Enable live streaming mode to continuously poll for new logs. + Live stream logs. --log-level Set the log output verbosity. [possible values: trace, debug, info, warn, error] --poll-interval - Polling interval in seconds for live streaming mode. + Poll interval in seconds. Only used when --live is specified. [default: 2] @@ -53,4 +53,4 @@ Options: -h, --help Print help (see a summary with '-h') -``` \ No newline at end of file +``` From 9192f275b3acefb8631e21f40b0920612a95d5bd Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Tue, 12 Aug 2025 11:41:43 +0200 Subject: [PATCH 06/19] fixes --- src/commands/logs/list.rs | 108 +++++++++++++++++++++++++++++++------- 1 file changed, 88 insertions(+), 20 deletions(-) diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index 4c04bbdf46..59e04613d0 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -200,6 +200,30 @@ impl LogDeduplicator { } } +/// Returns the updated consecutive all-new batch count and whether we should warn. +/// +/// A batch is considered "all-new" if `fetched_count > 0` and `unique_count == fetched_count`. +/// - If the batch is all-new, the counter increments; when it reaches `threshold`, we reset it to 0 and return `should_warn = true`. +/// - If the batch is not all-new (including `fetched_count == 0`), the counter resets to 0 and `should_warn = false`. +fn evaluate_all_new_batch_state( + previous_count: usize, + fetched_count: usize, + unique_count: usize, + threshold: usize, +) -> (usize, bool) { + let all_new_batch = fetched_count > 0 && unique_count == fetched_count; + if all_new_batch { + let updated = previous_count + 1; + if updated >= threshold { + (0, true) + } else { + (updated, false) + } + } else { + (0, false) + } +} + fn execute_live_streaming( api: &Api, org: &str, @@ -211,7 +235,7 @@ fn execute_live_streaming( let mut deduplicator = LogDeduplicator::new(MAX_DEDUP_BUFFER_SIZE); let poll_duration = Duration::from_secs(args.poll_interval); let mut consecutive_new_only_count = 0; - const WARNING_THRESHOLD: usize = 3; // Show message every 3 consecutive empty polls + const WARNING_THRESHOLD: usize = 3; // Show message every 3 consecutive all-new polls println!("Starting live log streaming..."); println!( @@ -230,6 +254,8 @@ fn execute_live_streaming( .add("Trace"); let mut header_printed = false; + // Holds a warning message to be printed after the current batch of rows for visibility + let mut pending_warning: Option = None; loop { let options = FetchEventsOptions { @@ -248,28 +274,31 @@ fn execute_live_streaming( .fetch_organization_events(org, &options) { Ok(logs) => { + let fetched_count = logs.len(); let unique_logs = deduplicator.add_logs(logs); - if unique_logs.is_empty() { - consecutive_new_only_count += 1; - - if consecutive_new_only_count >= WARNING_THRESHOLD { - if args.query.trim().is_empty() { - eprintln!("\nNo logs found in the last {WARNING_THRESHOLD} polls."); - } else { - eprintln!( - "\nNo logs found in the last {WARNING_THRESHOLD} polls. Consider adjusting your query filter: \"{}\"", - args.query - ); - } - - // Reset counter to show again after the next threshold - consecutive_new_only_count = 0; - } - } else { - consecutive_new_only_count = 0; + let (new_count, should_warn) = evaluate_all_new_batch_state( + consecutive_new_only_count, + fetched_count, + unique_logs.len(), + WARNING_THRESHOLD, + ); + consecutive_new_only_count = new_count; + if should_warn { + let suggestion_suffix = if args.query.trim().is_empty() { + Cow::Borrowed("") + } else { + Cow::Owned(format!(" (current filter: \"{}\")", args.query)) + }; + let msg = format!( + "Only new logs received in the last {WARNING_THRESHOLD} polls. You may be missing some logs. Consider narrowing your query filter{}.", + suggestion_suffix + ); + pending_warning = Some(msg); + } - // Add new logs to table + // Add new logs to table (if any) + if !unique_logs.is_empty() { for log in unique_logs { let row = table.add_row(); row.add(&log.item_id) @@ -290,6 +319,16 @@ fn execute_live_streaming( // Clear rows to free memory but keep the table structure for reuse table.clear_rows(); } + + // Print any pending warning AFTER the batch rows to maximize visibility + if let Some(msg) = pending_warning.take() { + // Style: bold black text on bright yellow background, with spacing and banner + const BANNER_WIDTH: usize = 100; + let line = "=".repeat(BANNER_WIDTH); + let reset = "\x1b[0m"; + let style = "\x1b[30;103;1m"; // black on bright yellow, bold + eprintln!("\n\n{}\n{} {} {}\n{}\n\n", line, style, msg, reset, line); + } } Err(e) => { eprintln!("Error fetching logs: {e}"); @@ -413,6 +452,35 @@ mod tests { assert_eq!(deduplicator.buffer.len(), 4); } + #[test] + fn test_evaluate_all_new_batch_state_increments_and_warns() { + let threshold = 3; + // First all-new batch + let (count1, warn1) = evaluate_all_new_batch_state(0, 5, 5, threshold); + assert_eq!(count1, 1); + assert!(!warn1); + + // Second all-new batch + let (count2, warn2) = evaluate_all_new_batch_state(count1, 2, 2, threshold); + assert_eq!(count2, 2); + assert!(!warn2); + + // Third all-new batch should warn and reset + let (count3, warn3) = evaluate_all_new_batch_state(count2, 10, 10, threshold); + assert_eq!(count3, 0); + assert!(warn3); + + // Non all-new batch resets + let (count4, warn4) = evaluate_all_new_batch_state(2, 4, 3, threshold); + assert_eq!(count4, 0); + assert!(!warn4); + + // Empty fetch resets + let (count5, warn5) = evaluate_all_new_batch_state(2, 0, 0, threshold); + assert_eq!(count5, 0); + assert!(!warn5); + } + #[test] fn test_is_numeric_project_id_purely_numeric() { assert!(is_numeric_project_id("123456")); From 02b75014094ee58710d03d3425f9212b8be18481 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Tue, 12 Aug 2025 11:44:27 +0200 Subject: [PATCH 07/19] validate poll-interval --- src/commands/logs/list.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index 59e04613d0..647d44f0cf 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -24,6 +24,16 @@ fn validate_max_rows(s: &str) -> Result { } } +/// Validate that poll-interval is a positive integer (> 0) +fn validate_poll_interval(s: &str) -> Result { + let value = s.parse()?; + if value > 0 { + Ok(value) + } else { + Err(anyhow::anyhow!("poll-interval must be a positive integer")) + } +} + /// Check if a project identifier is numeric (project ID) or string (project slug) fn is_numeric_project_id(project: &str) -> bool { !project.is_empty() && project.chars().all(|c| c.is_ascii_digit()) @@ -63,7 +73,8 @@ pub(super) struct ListLogsArgs { live: bool, #[arg(long = "poll-interval", default_value = "2")] - #[arg(help = "Poll interval in seconds. Only used when --live is specified.")] + #[arg(value_parser = validate_poll_interval)] + #[arg(help = "Poll interval in seconds (must be > 0). Only used when --live is specified.")] poll_interval: u64, } From fddcf0ffa4e6d61dfed1df2502cca1c47111d946 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Tue, 12 Aug 2025 12:25:49 +0200 Subject: [PATCH 08/19] fix test --- tests/integration/_cases/logs/logs-list-help.trycmd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/_cases/logs/logs-list-help.trycmd b/tests/integration/_cases/logs/logs-list-help.trycmd index 6811658b7c..8329c4874c 100644 --- a/tests/integration/_cases/logs/logs-list-help.trycmd +++ b/tests/integration/_cases/logs/logs-list-help.trycmd @@ -40,7 +40,7 @@ Options: Set the log output verbosity. [possible values: trace, debug, info, warn, error] --poll-interval - Poll interval in seconds. Only used when --live is specified. + Poll interval in seconds (must be > 0). Only used when --live is specified. [default: 2] From 7ee4ac896a7f6b88f1679fdf2f5d78044620b0e3 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Tue, 12 Aug 2025 12:34:03 +0200 Subject: [PATCH 09/19] lint fixes --- src/commands/logs/list.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index 647d44f0cf..34be873c77 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -302,8 +302,7 @@ fn execute_live_streaming( Cow::Owned(format!(" (current filter: \"{}\")", args.query)) }; let msg = format!( - "Only new logs received in the last {WARNING_THRESHOLD} polls. You may be missing some logs. Consider narrowing your query filter{}.", - suggestion_suffix + "Only new logs received in the last {WARNING_THRESHOLD} polls. You may be missing some logs. Consider narrowing your query filter{suggestion_suffix}." ); pending_warning = Some(msg); } @@ -338,7 +337,7 @@ fn execute_live_streaming( let line = "=".repeat(BANNER_WIDTH); let reset = "\x1b[0m"; let style = "\x1b[30;103;1m"; // black on bright yellow, bold - eprintln!("\n\n{}\n{} {} {}\n{}\n\n", line, style, msg, reset, line); + eprintln!("\n\n{line}\n{style} {msg} {reset}\n{line}\n\n"); } } Err(e) => { From ab61f68790d3221afd7c479c74d02f37272d450b Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Tue, 12 Aug 2025 14:20:46 +0200 Subject: [PATCH 10/19] make code nicer --- src/commands/logs/list.rs | 111 +++++++++++++++++++++++--------------- 1 file changed, 69 insertions(+), 42 deletions(-) diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index 34be873c77..8632f3b19b 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -211,27 +211,52 @@ impl LogDeduplicator { } } -/// Returns the updated consecutive all-new batch count and whether we should warn. +/// Tracks consecutive batches of all-new logs and manages warning state. /// -/// A batch is considered "all-new" if `fetched_count > 0` and `unique_count == fetched_count`. -/// - If the batch is all-new, the counter increments; when it reaches `threshold`, we reset it to 0 and return `should_warn = true`. -/// - If the batch is not all-new (including `fetched_count == 0`), the counter resets to 0 and `should_warn = false`. -fn evaluate_all_new_batch_state( - previous_count: usize, - fetched_count: usize, - unique_count: usize, - threshold: usize, -) -> (usize, bool) { - let all_new_batch = fetched_count > 0 && unique_count == fetched_count; - if all_new_batch { - let updated = previous_count + 1; - if updated >= threshold { - (0, true) +/// A batch is "all-new" when every fetched log is unique (no duplicates). +/// This struct tracks how many consecutive all-new batches we've seen and +/// warns when the count reaches the threshold, suggesting the user might be +/// missing some logs due to overly broad filtering. +#[derive(Debug)] +struct ConsecutiveNewOnlyTracker { + consecutive_count: usize, + warning_threshold: usize, +} + +impl ConsecutiveNewOnlyTracker { + /// Creates a new tracker with the specified warning threshold. + fn new(warning_threshold: usize) -> Self { + Self { + consecutive_count: 0, + warning_threshold, + } + } + + /// Processes a new batch and returns whether to show a warning. + /// + /// A batch is considered "all-new" if `fetched_count > 0` and `unique_count == fetched_count`. + /// Returns `true` when the warning threshold is reached, `false` otherwise. + fn process_batch(&mut self, fetched_count: usize, unique_count: usize) -> bool { + let is_all_new_batch = fetched_count > 0 && unique_count == fetched_count; + + if is_all_new_batch { + self.consecutive_count += 1; + if self.consecutive_count >= self.warning_threshold { + self.consecutive_count = 0; // Reset counter + true // Show warning + } else { + false // No warning yet + } } else { - (updated, false) + self.consecutive_count = 0; // Reset counter + false // No warning } - } else { - (0, false) + } + + /// Gets the current consecutive count (useful for debugging/testing). + #[cfg(test)] + fn consecutive_count(&self) -> usize { + self.consecutive_count } } @@ -245,8 +270,7 @@ fn execute_live_streaming( ) -> Result<()> { let mut deduplicator = LogDeduplicator::new(MAX_DEDUP_BUFFER_SIZE); let poll_duration = Duration::from_secs(args.poll_interval); - let mut consecutive_new_only_count = 0; - const WARNING_THRESHOLD: usize = 3; // Show message every 3 consecutive all-new polls + let mut new_only_tracker = ConsecutiveNewOnlyTracker::new(3); // Warn after 3 consecutive batches of only new logs println!("Starting live log streaming..."); println!( @@ -288,21 +312,16 @@ fn execute_live_streaming( let fetched_count = logs.len(); let unique_logs = deduplicator.add_logs(logs); - let (new_count, should_warn) = evaluate_all_new_batch_state( - consecutive_new_only_count, - fetched_count, - unique_logs.len(), - WARNING_THRESHOLD, - ); - consecutive_new_only_count = new_count; + let should_warn = new_only_tracker.process_batch(fetched_count, unique_logs.len()); if should_warn { let suggestion_suffix = if args.query.trim().is_empty() { - Cow::Borrowed("") + "" } else { - Cow::Owned(format!(" (current filter: \"{}\")", args.query)) + &format!(" (current filter: \"{}\")", args.query) }; let msg = format!( - "Only new logs received in the last {WARNING_THRESHOLD} polls. You may be missing some logs. Consider narrowing your query filter{suggestion_suffix}." + "Only new logs received in the last {} polls. You may be missing some logs. Consider narrowing your query filter{suggestion_suffix}.", + new_only_tracker.warning_threshold ); pending_warning = Some(msg); } @@ -463,31 +482,39 @@ mod tests { } #[test] - fn test_evaluate_all_new_batch_state_increments_and_warns() { - let threshold = 3; + fn test_consecutive_new_only_tracker_creation() { + let tracker = ConsecutiveNewOnlyTracker::new(5); + assert_eq!(tracker.consecutive_count(), 0); + assert_eq!(tracker.warning_threshold, 5); + } + + #[test] + fn test_consecutive_new_only_tracker_increments_and_warns() { + let mut tracker = ConsecutiveNewOnlyTracker::new(3); + // First all-new batch - let (count1, warn1) = evaluate_all_new_batch_state(0, 5, 5, threshold); - assert_eq!(count1, 1); + let warn1 = tracker.process_batch(5, 5); + assert_eq!(tracker.consecutive_count(), 1); assert!(!warn1); // Second all-new batch - let (count2, warn2) = evaluate_all_new_batch_state(count1, 2, 2, threshold); - assert_eq!(count2, 2); + let warn2 = tracker.process_batch(2, 2); + assert_eq!(tracker.consecutive_count(), 2); assert!(!warn2); // Third all-new batch should warn and reset - let (count3, warn3) = evaluate_all_new_batch_state(count2, 10, 10, threshold); - assert_eq!(count3, 0); + let warn3 = tracker.process_batch(10, 10); + assert_eq!(tracker.consecutive_count(), 0); assert!(warn3); // Non all-new batch resets - let (count4, warn4) = evaluate_all_new_batch_state(2, 4, 3, threshold); - assert_eq!(count4, 0); + let warn4 = tracker.process_batch(4, 3); + assert_eq!(tracker.consecutive_count(), 0); assert!(!warn4); // Empty fetch resets - let (count5, warn5) = evaluate_all_new_batch_state(2, 0, 0, threshold); - assert_eq!(count5, 0); + let warn5 = tracker.process_batch(0, 0); + assert_eq!(tracker.consecutive_count(), 0); assert!(!warn5); } From b2c99561db411d506b7e1624b952ade1db4d61ec Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Tue, 12 Aug 2025 14:28:04 +0200 Subject: [PATCH 11/19] remove unused files --- .../_cases/logs/logs-help-windows.trycmd | 32 ------------------- .../_cases/logs/logs-list-basic.trycmd | 6 ---- 2 files changed, 38 deletions(-) delete mode 100644 tests/integration/_cases/logs/logs-help-windows.trycmd delete mode 100644 tests/integration/_cases/logs/logs-list-basic.trycmd diff --git a/tests/integration/_cases/logs/logs-help-windows.trycmd b/tests/integration/_cases/logs/logs-help-windows.trycmd deleted file mode 100644 index 04279797ae..0000000000 --- a/tests/integration/_cases/logs/logs-help-windows.trycmd +++ /dev/null @@ -1,32 +0,0 @@ -``` -$ 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-basic.trycmd b/tests/integration/_cases/logs/logs-list-basic.trycmd deleted file mode 100644 index da6f07b998..0000000000 --- a/tests/integration/_cases/logs/logs-list-basic.trycmd +++ /dev/null @@ -1,6 +0,0 @@ -``` -$ sentry-cli logs list --org wat-org --project 12345 --max-rows 1 -? success -No logs found - -``` \ No newline at end of file From 95cb5cd32d9eb98d42aeaa15bddb92f7c5bf8006 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Tue, 12 Aug 2025 14:41:16 +0200 Subject: [PATCH 12/19] optimize performance --- src/commands/logs/list.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index 8632f3b19b..547bb0da0e 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -1,5 +1,5 @@ use std::borrow::Cow; -use std::collections::HashSet; +use std::collections::{HashSet, VecDeque}; use std::time::Duration; use anyhow::Result; @@ -173,7 +173,7 @@ struct LogDeduplicator { /// Set of seen log IDs for quick lookup seen_ids: HashSet, /// Buffer of log entries in order (for maintaining size limit) - buffer: Vec, + buffer: VecDeque, /// Maximum size of the buffer max_size: usize, } @@ -184,7 +184,7 @@ impl LogDeduplicator { fn new(max_size: usize) -> Self { Self { seen_ids: HashSet::new(), - buffer: Vec::new(), + buffer: VecDeque::new(), max_size, } } @@ -196,15 +196,16 @@ impl LogDeduplicator { for log in new_logs { if !self.seen_ids.contains(&log.item_id) { self.seen_ids.insert(log.item_id.clone()); - self.buffer.push(log.clone()); + self.buffer.push_back(log.clone()); unique_logs.push(log); } } // Maintain buffer size limit by removing oldest entries while self.buffer.len() > self.max_size { - let removed_log = self.buffer.remove(0); - self.seen_ids.remove(&removed_log.item_id); + if let Some(removed_log) = self.buffer.pop_front() { + self.seen_ids.remove(&removed_log.item_id); + } } unique_logs From 18e6ceb465a715a5ac8cc741c1868f1ed6e47a3f Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Tue, 12 Aug 2025 14:51:20 +0200 Subject: [PATCH 13/19] clean up tests --- src/commands/logs/list.rs | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index 547bb0da0e..7a70f4611d 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -452,36 +452,6 @@ mod tests { assert_eq!(new_unique_logs.len(), 1); } - #[test] - fn test_log_deduplicator_mixed_new_and_old_logs() { - let mut deduplicator = LogDeduplicator::new(10); - - // Add initial logs - let initial_logs = vec![ - create_test_log("1", "test message 1"), - create_test_log("2", "test message 2"), - ]; - let unique_logs1 = deduplicator.add_logs(initial_logs); - assert_eq!(unique_logs1.len(), 2); - - // Add mix of new and old logs - let mixed_logs = vec![ - create_test_log("1", "test message 1"), // old - create_test_log("3", "test message 3"), // new - create_test_log("2", "test message 2"), // old - create_test_log("4", "test message 4"), // new - ]; - let unique_logs2 = deduplicator.add_logs(mixed_logs); - - // Should only return the new logs (3 and 4) - assert_eq!(unique_logs2.len(), 2); - assert_eq!(unique_logs2[0].item_id, "3"); - assert_eq!(unique_logs2[1].item_id, "4"); - - assert_eq!(deduplicator.seen_ids.len(), 4); - assert_eq!(deduplicator.buffer.len(), 4); - } - #[test] fn test_consecutive_new_only_tracker_creation() { let tracker = ConsecutiveNewOnlyTracker::new(5); From 47293e40a424c642f66e17be6d4b29d2b4e7dcb8 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Wed, 3 Sep 2025 11:48:18 +0200 Subject: [PATCH 14/19] get rid of table for log streaming --- Cargo.lock | 33 ++++++++++++++++++++++ Cargo.toml | 1 + src/commands/logs/list.rs | 41 +++++++-------------------- src/utils/formatting.rs | 59 --------------------------------------- 4 files changed, 44 insertions(+), 90 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 010d0a3463..e4d3e39ac5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,6 +60,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -953,6 +959,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -1195,6 +1207,17 @@ dependencies = [ "serde", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "heck" version = "0.5.0" @@ -1760,6 +1783,15 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lru" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ea4e65087ff52f3862caff188d489f1fab49a0cb09e01b2e3f1a617b10aaed" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lzma-rs" version = "0.3.0" @@ -2723,6 +2755,7 @@ dependencies = [ "lazy_static", "libc", "log", + "lru", "mac-process-info", "magic_string", "mockito", diff --git a/Cargo.toml b/Cargo.toml index 5aa663d144..3f0b91a5cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,7 @@ data-encoding = "2.3.3" magic_string = "0.3.4" chrono-tz = "0.8.4" secrecy = "0.8.0" +lru = "0.16.0" [dev-dependencies] assert_cmd = "2.0.11" diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index 7a70f4611d..7309f3eb3a 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -279,18 +279,7 @@ fn execute_live_streaming( args.poll_interval ); - // Set up table with headers and print header once - let mut table = Table::new(); - table - .title_row() - .add("Item ID") - .add("Timestamp") - .add("Severity") - .add("Message") - .add("Trace"); - - let mut header_printed = false; - // Holds a warning message to be printed after the current batch of rows for visibility + // Holds a warning message to be printed after the current batch of logs for visibility let mut pending_warning: Option = None; loop { @@ -327,30 +316,20 @@ fn execute_live_streaming( pending_warning = Some(msg); } - // Add new logs to table (if any) + // Print new logs in human-readable format if !unique_logs.is_empty() { for log in unique_logs { - 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 !header_printed { - // Print header with first data batch so column widths match actual data - table.print_table_start(); - header_printed = true; - } else { - // Print only the rows (without table borders) for subsequent batches - table.print_rows_only(); + println!( + "{} | {} | {} | {}", + log.timestamp, + log.severity.as_deref().unwrap_or(""), + log.trace.as_deref().unwrap_or(""), + log.message.as_deref().unwrap_or("") + ); } - // Clear rows to free memory but keep the table structure for reuse - table.clear_rows(); } - // Print any pending warning AFTER the batch rows to maximize visibility + // Print any pending warning AFTER the batch logs to maximize visibility if let Some(msg) = pending_warning.take() { // Style: bold black text on bright yellow background, with spacing and banner const BANNER_WIDTH: usize = 100; diff --git a/src/utils/formatting.rs b/src/utils/formatting.rs index 69b7e21617..d7ac855960 100644 --- a/src/utils/formatting.rs +++ b/src/utils/formatting.rs @@ -98,65 +98,6 @@ impl Table { } tbl.print_tty(false).ok(); } - - /// Print header with first data batch for streaming mode - /// This ensures header column widths match the actual data by letting prettytable - /// size columns based on both header and data content together - pub fn print_table_start(&self) { - let mut tbl = prettytable::Table::new(); - // Use the same base format as print_rows_only but add header separator - let mut format = *prettytable::format::consts::FORMAT_NO_BORDER; - format.column_separator('|'); - format.padding(1, 1); - format.borders('|'); // Add left and right borders - // Add top border above the header - format.separator( - prettytable::format::LinePosition::Top, - prettytable::format::LineSeparator::new('-', '+', '+', '+'), - ); - // Add separator line under the header - format.separator( - prettytable::format::LinePosition::Title, - prettytable::format::LineSeparator::new('-', '+', '+', '+'), - ); - tbl.set_format(format); - - if let Some(ref title_row) = self.title_row { - tbl.set_titles(title_row.make_row()); - } - - for row in &self.rows { - tbl.add_row(row.make_row()); - } - - tbl.print_tty(false).ok(); - } - - /// Print only the current rows with column separators but no borders for streaming mode - pub fn print_rows_only(&self) { - if self.is_empty() { - return; - } - - let mut tbl = prettytable::Table::new(); - // Use format with column separators (|) and side borders, but no top/bottom borders - let mut format = *prettytable::format::consts::FORMAT_NO_BORDER; - format.column_separator('|'); - format.padding(1, 1); - format.borders('|'); // Add left and right borders - tbl.set_format(format); - - for row in &self.rows { - tbl.add_row(row.make_row()); - } - - tbl.print_tty(false).ok(); - } - - /// Clear all rows but keep the header for reuse in streaming mode - pub fn clear_rows(&mut self) { - self.rows.clear(); - } } impl Default for Table { From 3898a27a930e92e3c85ccc03771c92010dd40fbb Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Wed, 3 Sep 2025 12:06:23 +0200 Subject: [PATCH 15/19] use LRU cache lib --- src/commands/logs/list.rs | 35 ++++++++++++++--------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index 7309f3eb3a..c9cd2ae42f 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -1,9 +1,10 @@ use std::borrow::Cow; -use std::collections::{HashSet, VecDeque}; +use std::num::NonZeroUsize; use std::time::Duration; use anyhow::Result; use clap::Args; +use lru::LruCache; use crate::api::{Api, Dataset, FetchEventsOptions, LogEntry}; use crate::config::Config; @@ -168,14 +169,10 @@ fn execute_single_fetch( Ok(()) } -/// Manages deduplication of log entries with a bounded buffer +/// Manages deduplication of log entries using an LRU cache struct LogDeduplicator { - /// Set of seen log IDs for quick lookup - seen_ids: HashSet, - /// Buffer of log entries in order (for maintaining size limit) - buffer: VecDeque, - /// Maximum size of the buffer - max_size: usize, + /// LRU cache of seen log IDs + seen_ids: LruCache, } const MAX_DEDUP_BUFFER_SIZE: usize = 10_000; @@ -183,9 +180,11 @@ const MAX_DEDUP_BUFFER_SIZE: usize = 10_000; impl LogDeduplicator { fn new(max_size: usize) -> Self { Self { - seen_ids: HashSet::new(), - buffer: VecDeque::new(), - max_size, + seen_ids: LruCache::new( + max_size + .try_into() + .unwrap_or(NonZeroUsize::new(MAX_DEDUP_BUFFER_SIZE).expect("")), + ), } } @@ -194,20 +193,14 @@ impl LogDeduplicator { let mut unique_logs = Vec::new(); for log in new_logs { - if !self.seen_ids.contains(&log.item_id) { - self.seen_ids.insert(log.item_id.clone()); - self.buffer.push_back(log.clone()); + // If the log ID is not in the cache, it's a new log + if self.seen_ids.get(&log.item_id).is_none() { + // Add to cache (this will evict oldest entries if at capacity) + self.seen_ids.put(log.item_id.clone(), ()); unique_logs.push(log); } } - // Maintain buffer size limit by removing oldest entries - while self.buffer.len() > self.max_size { - if let Some(removed_log) = self.buffer.pop_front() { - self.seen_ids.remove(&removed_log.item_id); - } - } - unique_logs } } From 216dc3003460d69a53730d34ac6899983db5de01 Mon Sep 17 00:00:00 2001 From: Daniel Szoke Date: Thu, 4 Sep 2025 11:59:08 +0200 Subject: [PATCH 16/19] some minor refactors --- src/commands/logs/list.rs | 81 +++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 42 deletions(-) diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index c9cd2ae42f..cc2984a3f0 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -188,20 +188,20 @@ impl LogDeduplicator { } } - /// Add new logs and return only the ones that haven't been seen before - fn add_logs(&mut self, new_logs: Vec) -> Vec { - let mut unique_logs = Vec::new(); - - for log in new_logs { - // If the log ID is not in the cache, it's a new log - if self.seen_ids.get(&log.item_id).is_none() { - // Add to cache (this will evict oldest entries if at capacity) - self.seen_ids.put(log.item_id.clone(), ()); - unique_logs.push(log); - } - } - - unique_logs + /// Add new logs and return an iterator overonly the ones that haven't been seen before + fn add_logs<'a>(&'a mut self, new_logs: &'a [LogEntry]) -> impl Iterator { + new_logs + .iter() + .filter(|log| match self.seen_ids.get(&log.item_id) { + // If log ID is in the cache, we have seen it already + Some(_) => false, + + // If log ID is not in the cache, we have not seen it yet + None => { + self.seen_ids.put(log.item_id.clone(), ()); + true + } + }) } } @@ -268,13 +268,10 @@ fn execute_live_streaming( println!("Starting live log streaming..."); println!( - "Polling every {} seconds. Press Ctrl+C to stop.", + "Polling every {} seconds. Press ⌃C to stop.", args.poll_interval ); - // Holds a warning message to be printed after the current batch of logs for visibility - let mut pending_warning: Option = None; - loop { let options = FetchEventsOptions { dataset: Dataset::Logs, @@ -293,37 +290,35 @@ fn execute_live_streaming( { Ok(logs) => { let fetched_count = logs.len(); - let unique_logs = deduplicator.add_logs(logs); + let unique_logs = deduplicator.add_logs(&logs).collect::>(); let should_warn = new_only_tracker.process_batch(fetched_count, unique_logs.len()); + + // Print new logs in human-readable format + for log in unique_logs { + println!( + "{} | {} | {} | {}", + log.timestamp, + log.severity.as_deref().unwrap_or(""), + log.trace.as_deref().unwrap_or(""), + log.message.as_deref().unwrap_or("") + ); + } + + // Print any pending warning AFTER the batch logs to maximize visibility if should_warn { + // compute warning message let suggestion_suffix = if args.query.trim().is_empty() { "" } else { &format!(" (current filter: \"{}\")", args.query) }; + let msg = format!( "Only new logs received in the last {} polls. You may be missing some logs. Consider narrowing your query filter{suggestion_suffix}.", new_only_tracker.warning_threshold ); - pending_warning = Some(msg); - } - // Print new logs in human-readable format - if !unique_logs.is_empty() { - for log in unique_logs { - println!( - "{} | {} | {} | {}", - log.timestamp, - log.severity.as_deref().unwrap_or(""), - log.trace.as_deref().unwrap_or(""), - log.message.as_deref().unwrap_or("") - ); - } - } - - // Print any pending warning AFTER the batch logs to maximize visibility - if let Some(msg) = pending_warning.take() { // Style: bold black text on bright yellow background, with spacing and banner const BANNER_WIDTH: usize = 100; let line = "=".repeat(BANNER_WIDTH); @@ -368,7 +363,8 @@ mod tests { let log1 = create_test_log("1", "test message 1"); let log2 = create_test_log("2", "test message 2"); - let unique_logs = deduplicator.add_logs(vec![log1.clone(), log2.clone()]); + let logs = vec![log1.clone(), log2.clone()]; + let unique_logs = deduplicator.add_logs(&logs).collect::>(); assert_eq!(unique_logs.len(), 2); assert_eq!(deduplicator.seen_ids.len(), 2); @@ -382,11 +378,12 @@ mod tests { let log2 = create_test_log("2", "test message 2"); // Add logs first time - let unique_logs1 = deduplicator.add_logs(vec![log1.clone(), log2.clone()]); + let logs = vec![log1.clone(), log2.clone()]; + let unique_logs1 = deduplicator.add_logs(&logs).collect::>(); assert_eq!(unique_logs1.len(), 2); // Add same logs again - let unique_logs2 = deduplicator.add_logs(vec![log1.clone(), log2.clone()]); + let unique_logs2 = deduplicator.add_logs(&logs).collect::>(); assert_eq!(unique_logs2.len(), 0); // Should be empty as logs already seen assert_eq!(deduplicator.seen_ids.len(), 2); @@ -405,7 +402,7 @@ mod tests { create_test_log("5", "test message 5"), ]; - let unique_logs = deduplicator.add_logs(logs); + let unique_logs = deduplicator.add_logs(&logs).collect::>(); assert_eq!(unique_logs.len(), 5); // After adding 5 logs to a buffer with max size 3, the oldest 2 should be evicted @@ -415,12 +412,12 @@ mod tests { create_test_log("1", "test message 1"), create_test_log("2", "test message 2"), ]; - let duplicate_unique_logs = deduplicator.add_logs(duplicate_logs); + let duplicate_unique_logs = deduplicator.add_logs(&duplicate_logs).collect::>(); assert_eq!(duplicate_unique_logs.len(), 2); // Test that adding new logs still works let new_logs = vec![create_test_log("6", "test message 6")]; - let new_unique_logs = deduplicator.add_logs(new_logs); + let new_unique_logs = deduplicator.add_logs(&new_logs).collect::>(); assert_eq!(new_unique_logs.len(), 1); } From c3f2c4c05d56a5c473a5cf177d978b5ddb4544eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vjeran=20Grozdani=C4=87?= Date: Thu, 4 Sep 2025 12:53:14 +0200 Subject: [PATCH 17/19] Update src/commands/logs/list.rs Co-authored-by: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> --- src/commands/logs/list.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index cc2984a3f0..bd48f18db9 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -178,7 +178,7 @@ struct LogDeduplicator { const MAX_DEDUP_BUFFER_SIZE: usize = 10_000; impl LogDeduplicator { - fn new(max_size: usize) -> Self { + fn new(max_size: NonZeroUsize) -> Self { Self { seen_ids: LruCache::new( max_size From f07e846e328eab0419520e393ec0a11e46ccd2cb Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Thu, 4 Sep 2025 13:02:20 +0200 Subject: [PATCH 18/19] refactor --- src/commands/logs/list.rs | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index bd48f18db9..741275668e 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -1,5 +1,5 @@ use std::borrow::Cow; -use std::num::NonZeroUsize; +use std::num::{NonZero, NonZeroUsize}; use std::time::Duration; use anyhow::Result; @@ -175,16 +175,10 @@ struct LogDeduplicator { seen_ids: LruCache, } -const MAX_DEDUP_BUFFER_SIZE: usize = 10_000; - impl LogDeduplicator { fn new(max_size: NonZeroUsize) -> Self { Self { - seen_ids: LruCache::new( - max_size - .try_into() - .unwrap_or(NonZeroUsize::new(MAX_DEDUP_BUFFER_SIZE).expect("")), - ), + seen_ids: LruCache::new(max_size), } } @@ -262,7 +256,8 @@ fn execute_live_streaming( fields: &[&str], args: &ListLogsArgs, ) -> Result<()> { - let mut deduplicator = LogDeduplicator::new(MAX_DEDUP_BUFFER_SIZE); + let mut deduplicator = + LogDeduplicator::new(NonZero::new(args.max_rows).expect("max-rows should be non-zero")); let poll_duration = Duration::from_secs(args.poll_interval); let mut new_only_tracker = ConsecutiveNewOnlyTracker::new(3); // Warn after 3 consecutive batches of only new logs @@ -352,13 +347,13 @@ mod tests { #[test] fn test_log_deduplicator_new() { - let deduplicator = LogDeduplicator::new(100); + let deduplicator = LogDeduplicator::new(NonZeroUsize::new(100).unwrap()); assert_eq!(deduplicator.seen_ids.len(), 0); } #[test] fn test_log_deduplicator_add_unique_logs() { - let mut deduplicator = LogDeduplicator::new(10); + let mut deduplicator = LogDeduplicator::new(NonZeroUsize::new(10).unwrap()); let log1 = create_test_log("1", "test message 1"); let log2 = create_test_log("2", "test message 2"); @@ -372,7 +367,7 @@ mod tests { #[test] fn test_log_deduplicator_deduplicate_logs() { - let mut deduplicator = LogDeduplicator::new(10); + let mut deduplicator = LogDeduplicator::new(NonZeroUsize::new(10).unwrap()); let log1 = create_test_log("1", "test message 1"); let log2 = create_test_log("2", "test message 2"); @@ -391,7 +386,7 @@ mod tests { #[test] fn test_log_deduplicator_buffer_size_limit() { - let mut deduplicator = LogDeduplicator::new(3); + let mut deduplicator = LogDeduplicator::new(NonZeroUsize::new(3).unwrap()); // Add 5 logs to a buffer with max size 3 let logs = vec![ From ec2468f2cca7420b6371295db33855dd0652f3f1 Mon Sep 17 00:00:00 2001 From: Vjeran Grozdanic Date: Thu, 4 Sep 2025 14:04:16 +0200 Subject: [PATCH 19/19] feedback --- src/commands/logs/list.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/commands/logs/list.rs b/src/commands/logs/list.rs index 741275668e..64b97b6d72 100644 --- a/src/commands/logs/list.rs +++ b/src/commands/logs/list.rs @@ -1,5 +1,5 @@ use std::borrow::Cow; -use std::num::{NonZero, NonZeroUsize}; +use std::num::NonZeroUsize; use std::time::Duration; use anyhow::Result; @@ -12,10 +12,10 @@ 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()?; +fn validate_max_rows(s: &str) -> Result { + let value = s.parse::()?; if MAX_ROWS_RANGE.contains(&value) { - Ok(value) + NonZeroUsize::new(value).ok_or_else(|| anyhow::anyhow!("max-rows must be greater than 0")) } else { Err(anyhow::anyhow!( "max-rows must be between {} and {}", @@ -63,7 +63,7 @@ pub(super) struct ListLogsArgs { #[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, + max_rows: NonZeroUsize, #[arg(long = "query", default_value = "")] #[arg(help = "Query to filter logs. Example: \"level:error\"")] @@ -133,7 +133,7 @@ fn execute_single_fetch( project_id, cursor: None, query, - per_page: args.max_rows, + per_page: args.max_rows.get(), stats_period: "90d", sort: "-timestamp", }; @@ -151,7 +151,7 @@ fn execute_single_fetch( .add("Message") .add("Trace"); - for log in logs.iter().take(args.max_rows) { + for log in logs.iter().take(args.max_rows.get()) { let row = table.add_row(); row.add(&log.item_id) .add(&log.timestamp) @@ -256,8 +256,7 @@ fn execute_live_streaming( fields: &[&str], args: &ListLogsArgs, ) -> Result<()> { - let mut deduplicator = - LogDeduplicator::new(NonZero::new(args.max_rows).expect("max-rows should be non-zero")); + let mut deduplicator = LogDeduplicator::new(args.max_rows); let poll_duration = Duration::from_secs(args.poll_interval); let mut new_only_tracker = ConsecutiveNewOnlyTracker::new(3); // Warn after 3 consecutive batches of only new logs @@ -274,7 +273,7 @@ fn execute_live_streaming( project_id: project, cursor: None, query, - per_page: args.max_rows, + per_page: args.max_rows.get(), stats_period: "10m", sort: "-timestamp", };