Skip to content

feat(logs): Introduce logs command #2664

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Aug 6, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 88 additions & 2 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,
/// 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<Vec<LogEntry>> {
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(
Expand Down Expand Up @@ -2343,7 +2412,7 @@ pub struct ProcessedEvent {
pub tags: Option<Vec<ProcessedEventTag>>,
}

#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ProcessedEventUser {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
Expand Down Expand Up @@ -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,
Expand All @@ -2401,3 +2470,20 @@ pub struct Region {
pub struct RegionResponse {
pub regions: Vec<Region>,
}

/// Response structure for logs API
#[derive(Debug, Deserialize)]
struct LogsResponse {
data: Vec<LogEntry>,
}

/// 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<String>,
pub severity: Option<String>,
pub timestamp: String,
pub message: Option<String>,
}
2 changes: 2 additions & 0 deletions src/commands/derive_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -32,5 +33,6 @@ pub(super) struct SentryCLI {

#[derive(Subcommand)]
pub(super) enum SentryCLICommand {
Logs(LogsArgs),
SendMetric(SendMetricArgs),
}
13 changes: 13 additions & 0 deletions src/commands/logs/common_args.rs
Original file line number Diff line number Diff line change
@@ -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<String>,

#[arg(short = 'p', long = "project")]
#[arg(help = "The project ID or slug.")]
pub(super) project: Option<String>,
}
100 changes: 100 additions & 0 deletions src/commands/logs/list.rs
Original file line number Diff line number Diff line change
@@ -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<usize>,

#[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"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This value should either be user-configurable, or we should state in the command's --help text that we only display logs from the last hour

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So since the way we query the API is descending by time, and we limit to 100 rows via the per_page parameter, we will always get the 100 most recent logs. I did a quick test on our API with 90d and 1h, and the timing is not really different, so I'd propose defaulting to 90d and not making it user-configurable for now, that would always give the user the most recent logs. What do you think about that @szokeasaurusrex?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shellmayr, is the stats_period even needed in the query string? If not, let's just delete the struct field completely and not send the parameter, assuming that the default time period the server assumes is reasonable. Otherwise, I would just hardcode the 90d default

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@szokeasaurusrex I think it defaults to 14 days if not explicitly set

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm okay, I guess either the server default or a 90 day default is reasonable in that case

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather it be explicit, so the CLI doesn't rely on current, unspecified behaviour - that way the behaviour should stay the same if something changes in the API configuration.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very wise

..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(())
}
43 changes: 43 additions & 0 deletions src/commands/logs/mod.rs
Original file line number Diff line number Diff line change
@@ -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),
}
}
3 changes: 3 additions & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ mod files;
mod info;
mod issues;
mod login;
mod logs;
mod mobile_app;
mod monitors;
mod organizations;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -95,6 +97,7 @@ const UPDATE_NAGGER_CMDS: &[&str] = &[
"info",
"issues",
"login",
"logs",
"organizations",
"projects",
"releases",
Expand Down
10 changes: 4 additions & 6 deletions src/commands/send_metric/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");

Expand Down
1 change: 1 addition & 0 deletions tests/integration/_cases/help/help-windows.trycmd
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions tests/integration/_cases/help/help.trycmd
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions tests/integration/_cases/logs/logs-help.trycmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
$ sentry-cli logs --help
? success
Manage logs in Sentry.

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:
-o, --org <ORG>
The organization ID or slug.

-p, --project <PROJECT>
The project ID or slug.

--live
Live-tail logs (not implemented yet).

--header <KEY:VALUE>
Custom headers that should be attached to all requests
in key:value format.

--auth-token <AUTH_TOKEN>
Use the given Sentry auth token.

--log-level <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
3 changes: 3 additions & 0 deletions tests/integration/_cases/logs/logs-list-basic.trycmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
$ sentry-cli logs list --org wat-org --project wat-project --max-rows 0
? success
No logs found
Loading