Skip to content

Commit 150beb6

Browse files
feat(logs): Introduce logs command (#2664)
Add a new `logs` subcommand to the Sentry CLI, allowing users to manage and query logs from their organizations. This includes a `list` command to fetch and display log entries with options for filtering and pagination. Implemented common arguments for logs and integrated them into the command structure. Also added integration tests for the new functionality. Part of #2661 Live tailing will be done as a part of separate PR to make this a bit easier to review, it's already very large change. --------- Co-authored-by: Simon Hellmayr <simon.hellmayr@sentry.io>
1 parent 5f9403e commit 150beb6

File tree

16 files changed

+472
-11
lines changed

16 files changed

+472
-11
lines changed

src/api/mod.rs

Lines changed: 107 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,6 +1226,29 @@ impl<'a> AuthenticatedApi<'a> {
12261226
Ok(rv)
12271227
}
12281228

1229+
/// Fetch organization events from the specified dataset
1230+
pub fn fetch_organization_events(
1231+
&self,
1232+
org: &str,
1233+
options: &FetchEventsOptions,
1234+
) -> ApiResult<Vec<LogEntry>> {
1235+
let params = options.to_query_params();
1236+
let url = format!(
1237+
"/organizations/{}/events/?{}",
1238+
PathArg(org),
1239+
params.join("&")
1240+
);
1241+
1242+
let resp = self.get(&url)?;
1243+
1244+
if resp.status() == 404 {
1245+
return Err(ApiErrorKind::OrganizationNotFound.into());
1246+
}
1247+
1248+
let logs_response: LogsResponse = resp.convert()?;
1249+
Ok(logs_response.data)
1250+
}
1251+
12291252
/// List all issues associated with an organization and a project
12301253
pub fn list_organization_project_issues(
12311254
&self,
@@ -1390,6 +1413,71 @@ impl<'a> AuthenticatedApi<'a> {
13901413
}
13911414
}
13921415

1416+
/// Available datasets for fetching organization events
1417+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1418+
pub enum Dataset {
1419+
/// Our logs dataset
1420+
Logs,
1421+
}
1422+
1423+
impl Dataset {
1424+
/// Returns the string representation of the dataset
1425+
fn as_str(&self) -> &'static str {
1426+
match self {
1427+
Dataset::Logs => "logs",
1428+
}
1429+
}
1430+
}
1431+
1432+
impl fmt::Display for Dataset {
1433+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1434+
write!(f, "{}", self.as_str())
1435+
}
1436+
}
1437+
1438+
/// Options for fetching organization events
1439+
pub struct FetchEventsOptions<'a> {
1440+
/// Dataset to fetch events from
1441+
pub dataset: Dataset,
1442+
/// Fields to include in the response
1443+
pub fields: &'a [&'a str],
1444+
/// Project ID to filter events by
1445+
pub project_id: &'a str,
1446+
/// Cursor for pagination
1447+
pub cursor: Option<&'a str>,
1448+
/// Query string to filter events
1449+
pub query: &'a str,
1450+
/// Number of events per page
1451+
pub per_page: usize,
1452+
/// Time period for stats
1453+
pub stats_period: &'a str,
1454+
/// Sort order
1455+
pub sort: &'a str,
1456+
}
1457+
1458+
impl<'a> FetchEventsOptions<'a> {
1459+
/// Generate query parameters as a vector of strings
1460+
pub fn to_query_params(&self) -> Vec<String> {
1461+
let mut params = vec![format!("dataset={}", QueryArg(self.dataset.as_str()))];
1462+
1463+
for field in self.fields {
1464+
params.push(format!("field={}", QueryArg(field)));
1465+
}
1466+
1467+
if let Some(cursor) = self.cursor {
1468+
params.push(format!("cursor={}", QueryArg(cursor)));
1469+
}
1470+
1471+
params.push(format!("project={}", QueryArg(self.project_id)));
1472+
params.push(format!("query={}", QueryArg(self.query)));
1473+
params.push(format!("per_page={}", self.per_page));
1474+
params.push(format!("statsPeriod={}", QueryArg(self.stats_period)));
1475+
params.push(format!("sort={}", QueryArg(self.sort)));
1476+
1477+
params
1478+
}
1479+
}
1480+
13931481
impl RegionSpecificApi<'_> {
13941482
fn request(&self, method: Method, url: &str) -> ApiResult<ApiRequest> {
13951483
self.api
@@ -1609,8 +1697,6 @@ impl ApiRequest {
16091697
pipeline_env: Option<String>,
16101698
global_headers: Option<Vec<String>>,
16111699
) -> ApiResult<Self> {
1612-
debug!("request {} {}", method, url);
1613-
16141700
let mut headers = curl::easy::List::new();
16151701
headers.append("Expect:").ok();
16161702

@@ -1740,7 +1826,6 @@ impl ApiRequest {
17401826
let body = self.body.as_deref();
17411827
let (status, headers) =
17421828
send_req(&mut self.handle, out, body, self.progress_bar_mode.clone())?;
1743-
debug!("response status: {}", status);
17441829
Ok(ApiResponse {
17451830
status,
17461831
headers,
@@ -2343,7 +2428,7 @@ pub struct ProcessedEvent {
23432428
pub tags: Option<Vec<ProcessedEventTag>>,
23442429
}
23452430

2346-
#[derive(Clone, Debug, Deserialize)]
2431+
#[derive(Clone, Debug, Deserialize, Serialize)]
23472432
pub struct ProcessedEventUser {
23482433
#[serde(skip_serializing_if = "Option::is_none")]
23492434
pub id: Option<String>,
@@ -2377,7 +2462,7 @@ impl fmt::Display for ProcessedEventUser {
23772462
}
23782463
}
23792464

2380-
#[derive(Clone, Debug, Deserialize)]
2465+
#[derive(Clone, Debug, Deserialize, Serialize)]
23812466
pub struct ProcessedEventTag {
23822467
pub key: String,
23832468
pub value: String,
@@ -2401,3 +2486,20 @@ pub struct Region {
24012486
pub struct RegionResponse {
24022487
pub regions: Vec<Region>,
24032488
}
2489+
2490+
/// Response structure for logs API
2491+
#[derive(Debug, Deserialize)]
2492+
struct LogsResponse {
2493+
data: Vec<LogEntry>,
2494+
}
2495+
2496+
/// Log entry structure from the logs API
2497+
#[derive(Debug, Deserialize)]
2498+
pub struct LogEntry {
2499+
#[serde(rename = "sentry.item_id")]
2500+
pub item_id: String,
2501+
pub trace: Option<String>,
2502+
pub severity: Option<String>,
2503+
pub timestamp: String,
2504+
pub message: Option<String>,
2505+
}

src/commands/derive_parser.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::utils::auth_token::AuthToken;
22
use crate::utils::value_parsers::{auth_token_parser, kv_parser};
33
use clap::{command, ArgAction::SetTrue, Parser, Subcommand};
44

5+
use super::logs::LogsArgs;
56
use super::send_metric::SendMetricArgs;
67

78
#[derive(Parser)]
@@ -32,5 +33,6 @@ pub(super) struct SentryCLI {
3233

3334
#[derive(Subcommand)]
3435
pub(super) enum SentryCLICommand {
36+
Logs(LogsArgs),
3537
SendMetric(SendMetricArgs),
3638
}

src/commands/logs/list.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
use anyhow::Result;
2+
use clap::Args;
3+
4+
use crate::api::{Api, Dataset, FetchEventsOptions};
5+
use crate::config::Config;
6+
use crate::utils::formatting::Table;
7+
8+
const MAX_ROWS_RANGE: std::ops::RangeInclusive<usize> = 1..=1000;
9+
/// Validate that max_rows is in the allowed range
10+
fn validate_max_rows(s: &str) -> Result<usize> {
11+
let value = s.parse()?;
12+
if MAX_ROWS_RANGE.contains(&value) {
13+
Ok(value)
14+
} else {
15+
Err(anyhow::anyhow!(
16+
"max-rows must be between {} and {}",
17+
MAX_ROWS_RANGE.start(),
18+
MAX_ROWS_RANGE.end()
19+
))
20+
}
21+
}
22+
23+
/// Fields to fetch from the logs API
24+
const LOG_FIELDS: &[&str] = &[
25+
"sentry.item_id",
26+
"trace",
27+
"severity",
28+
"timestamp",
29+
"message",
30+
];
31+
32+
/// Arguments for listing logs
33+
#[derive(Args)]
34+
pub(super) struct ListLogsArgs {
35+
#[arg(short = 'o', long = "org")]
36+
#[arg(help = "The organization ID or slug.")]
37+
org: Option<String>,
38+
39+
#[arg(short = 'p', long = "project")]
40+
#[arg(help = "The project ID (slug not supported).")]
41+
project: Option<String>,
42+
43+
#[arg(long = "max-rows", default_value = "100")]
44+
#[arg(value_parser = validate_max_rows)]
45+
#[arg(help = format!("Maximum number of log entries to fetch and display (max {}).", MAX_ROWS_RANGE.end()))]
46+
max_rows: usize,
47+
48+
#[arg(long = "query", default_value = "")]
49+
#[arg(help = "Query to filter logs. Example: \"level:error\"")]
50+
query: String,
51+
}
52+
53+
pub(super) fn execute(args: ListLogsArgs) -> Result<()> {
54+
let config = Config::current();
55+
let (default_org, default_project) = config.get_org_and_project_defaults();
56+
57+
let org = args.org.as_ref().or(default_org.as_ref()).ok_or_else(|| {
58+
anyhow::anyhow!(
59+
"No organization specified. Please specify an organization using the --org argument."
60+
)
61+
})?;
62+
63+
let project = args
64+
.project
65+
.as_ref()
66+
.or(default_project.as_ref())
67+
.ok_or_else(|| {
68+
anyhow::anyhow!("No project specified. Use --project or set a default in config.")
69+
})?;
70+
71+
let api = Api::current();
72+
73+
let query = if args.query.is_empty() {
74+
None
75+
} else {
76+
Some(args.query.as_str())
77+
};
78+
79+
execute_single_fetch(&api, org, project, query, LOG_FIELDS, &args)
80+
}
81+
82+
fn execute_single_fetch(
83+
api: &Api,
84+
org: &str,
85+
project: &str,
86+
query: Option<&str>,
87+
fields: &[&str],
88+
args: &ListLogsArgs,
89+
) -> Result<()> {
90+
let options = FetchEventsOptions {
91+
dataset: Dataset::Logs,
92+
fields,
93+
project_id: project,
94+
cursor: None,
95+
query: query.unwrap_or(""),
96+
per_page: args.max_rows,
97+
stats_period: "90d",
98+
sort: "-timestamp",
99+
};
100+
101+
let logs = api
102+
.authenticated()?
103+
.fetch_organization_events(org, &options)?;
104+
105+
let mut table = Table::new();
106+
table
107+
.title_row()
108+
.add("Item ID")
109+
.add("Timestamp")
110+
.add("Severity")
111+
.add("Message")
112+
.add("Trace");
113+
114+
for log in logs.iter().take(args.max_rows) {
115+
let row = table.add_row();
116+
row.add(&log.item_id)
117+
.add(&log.timestamp)
118+
.add(log.severity.as_deref().unwrap_or(""))
119+
.add(log.message.as_deref().unwrap_or(""))
120+
.add(log.trace.as_deref().unwrap_or(""));
121+
}
122+
123+
if table.is_empty() {
124+
println!("No logs found");
125+
} else {
126+
table.print();
127+
}
128+
129+
Ok(())
130+
}

src/commands/logs/mod.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
mod list;
2+
3+
use self::list::ListLogsArgs;
4+
use super::derive_parser::{SentryCLI, SentryCLICommand};
5+
use anyhow::Result;
6+
use clap::ArgMatches;
7+
use clap::{Args, Command, Parser as _, Subcommand};
8+
9+
const LIST_ABOUT: &str = "List logs from your organization";
10+
11+
#[derive(Args)]
12+
pub(super) struct LogsArgs {
13+
#[command(subcommand)]
14+
subcommand: LogsSubcommand,
15+
}
16+
17+
#[derive(Subcommand)]
18+
#[command(about = "Manage logs in Sentry")]
19+
#[command(long_about = "Manage and query logs in Sentry. \
20+
This command provides access to log entries.")]
21+
enum LogsSubcommand {
22+
#[command(about = LIST_ABOUT)]
23+
#[command(long_about = format!("{LIST_ABOUT}. \
24+
Query and filter log entries from your Sentry projects. \
25+
Supports filtering by log level and custom queries."))]
26+
List(ListLogsArgs),
27+
}
28+
29+
pub(super) fn make_command(command: Command) -> Command {
30+
LogsSubcommand::augment_subcommands(command)
31+
}
32+
33+
pub(super) fn execute(_: &ArgMatches) -> Result<()> {
34+
let SentryCLICommand::Logs(LogsArgs { subcommand }) = SentryCLI::parse().command else {
35+
unreachable!("expected logs subcommand");
36+
};
37+
38+
match subcommand {
39+
LogsSubcommand::List(args) => list::execute(args),
40+
}
41+
}

src/commands/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ mod files;
2828
mod info;
2929
mod issues;
3030
mod login;
31+
mod logs;
3132
mod mobile_app;
3233
mod monitors;
3334
mod organizations;
@@ -57,6 +58,7 @@ macro_rules! each_subcommand {
5758
$mac!(info);
5859
$mac!(issues);
5960
$mac!(login);
61+
$mac!(logs);
6062
#[cfg(feature = "unstable-mobile-app")]
6163
$mac!(mobile_app);
6264
$mac!(monitors);
@@ -95,6 +97,7 @@ const UPDATE_NAGGER_CMDS: &[&str] = &[
9597
"info",
9698
"issues",
9799
"login",
100+
"logs",
98101
"organizations",
99102
"projects",
100103
"releases",

src/commands/send_metric/mod.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,10 @@ pub(super) fn make_command(command: Command) -> Command {
5959
}
6060

6161
pub(super) fn execute(_: &ArgMatches) -> Result<()> {
62-
// When adding a new subcommand to the derive_parser SentryCLI, replace the line below with the following:
63-
// let subcommand = match SentryCLI::parse().command {
64-
// SentryCLICommand::SendMetric(SendMetricArgs { subcommand }) => subcommand,
65-
// _ => panic!("expected send-metric subcommand"),
66-
// };
67-
let SentryCLICommand::SendMetric(SendMetricArgs { subcommand }) = SentryCLI::parse().command;
62+
let subcommand = match SentryCLI::parse().command {
63+
SentryCLICommand::SendMetric(SendMetricArgs { subcommand }) => subcommand,
64+
_ => unreachable!("expected send-metric subcommand"),
65+
};
6866

6967
log::warn!("{DEPRECATION_MESSAGE}");
7068

tests/integration/_cases/help/help-windows.trycmd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Commands:
1717
info Print information about the configuration and verify authentication.
1818
issues Manage issues in Sentry.
1919
login Authenticate with the Sentry server.
20+
logs Manage logs in Sentry
2021
monitors Manage cron monitors on Sentry.
2122
organizations Manage organizations on Sentry.
2223
projects Manage projects on Sentry.

0 commit comments

Comments
 (0)