-
-
Notifications
You must be signed in to change notification settings - Fork 232
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
Changes from 3 commits
ff53f3e
57ca614
2ea949b
49ed900
c8e60c0
519d55c
a015ab1
d836bf4
43e98c8
1b5d554
a5b1bb0
b7d5973
10240ce
e7f9805
4d4f99a
ccef59d
74f8596
fa1dde7
6c64120
3291c36
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -1226,6 +1226,29 @@ impl<'a> AuthenticatedApi<'a> { | |||||||||||||||||||||||||||||||||
Ok(rv) | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
/// Fetch organization events from the specified dataset | ||||||||||||||||||||||||||||||||||
pub fn fetch_organization_events( | ||||||||||||||||||||||||||||||||||
&self, | ||||||||||||||||||||||||||||||||||
org: &str, | ||||||||||||||||||||||||||||||||||
options: &FetchEventsOptions, | ||||||||||||||||||||||||||||||||||
) -> ApiResult<Vec<LogEntry>> { | ||||||||||||||||||||||||||||||||||
let params = options.to_query_params(); | ||||||||||||||||||||||||||||||||||
let url = format!( | ||||||||||||||||||||||||||||||||||
"/organizations/{}/events/?{}", | ||||||||||||||||||||||||||||||||||
PathArg(org), | ||||||||||||||||||||||||||||||||||
params.join("&") | ||||||||||||||||||||||||||||||||||
); | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
let resp = self.get(&url)?; | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
if resp.status() == 404 { | ||||||||||||||||||||||||||||||||||
return Err(ApiErrorKind::OrganizationNotFound.into()); | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
let logs_response: LogsResponse = resp.convert()?; | ||||||||||||||||||||||||||||||||||
Ok(logs_response.data) | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
/// List all issues associated with an organization and a project | ||||||||||||||||||||||||||||||||||
pub fn list_organization_project_issues( | ||||||||||||||||||||||||||||||||||
&self, | ||||||||||||||||||||||||||||||||||
|
@@ -1390,6 +1413,78 @@ impl<'a> AuthenticatedApi<'a> { | |||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
/// Available datasets for fetching organization events | ||||||||||||||||||||||||||||||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||||||||||||||||||||||||||||||||||
pub enum Dataset { | ||||||||||||||||||||||||||||||||||
/// Our logs dataset | ||||||||||||||||||||||||||||||||||
OurLogs, | ||||||||||||||||||||||||||||||||||
shellmayr marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
impl Dataset { | ||||||||||||||||||||||||||||||||||
/// Returns the string representation of the dataset | ||||||||||||||||||||||||||||||||||
fn as_str(&self) -> &'static str { | ||||||||||||||||||||||||||||||||||
match self { | ||||||||||||||||||||||||||||||||||
Dataset::OurLogs => "ourlogs", | ||||||||||||||||||||||||||||||||||
shellmayr marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
shellmayr marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
vgrozdanic marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
impl fmt::Display for Dataset { | ||||||||||||||||||||||||||||||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||||||||||||||||||||||||||||||||||
write!(f, "{}", self.as_str()) | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
/// Options for fetching organization events | ||||||||||||||||||||||||||||||||||
pub struct FetchEventsOptions<'a> { | ||||||||||||||||||||||||||||||||||
/// Dataset to fetch events from | ||||||||||||||||||||||||||||||||||
pub dataset: Dataset, | ||||||||||||||||||||||||||||||||||
/// Fields to include in the response | ||||||||||||||||||||||||||||||||||
pub fields: &'a [&'a str], | ||||||||||||||||||||||||||||||||||
/// Project ID to filter events by | ||||||||||||||||||||||||||||||||||
pub project_id: Option<&'a str>, | ||||||||||||||||||||||||||||||||||
szokeasaurusrex marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||
/// Cursor for pagination | ||||||||||||||||||||||||||||||||||
pub cursor: Option<&'a str>, | ||||||||||||||||||||||||||||||||||
szokeasaurusrex marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||
/// 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>, | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For now, all of these fields appear to always be being set to a Therefore, I would like to see the struct fields be made non-optional, so we can also get rid of the defaults in the code, and make the API simpler. We can always later make the fields optional, as needed.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @szokeasaurusrex Calls to the endpoint don't need to have a query, stats_period, per_page or sort. What are we achieving by making them non-optional? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I want to keep the Sentry CLI code simple. Making these optional means we have to add code paths to Sentry CLI, which for now, are not being used, because we always set the As a result, we have code paths, which are completely unused, to handle the The problem with keeping them as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @shellmayr As an alternative, I would also be okay with just hardcoding the defaults and removing these fields from the struct completely. I'm okay with whatever you think is more reasonable, just want to avoid the |
||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
impl<'a> FetchEventsOptions<'a> { | ||||||||||||||||||||||||||||||||||
/// Generate query parameters as a vector of strings | ||||||||||||||||||||||||||||||||||
pub fn to_query_params(&self) -> Vec<String> { | ||||||||||||||||||||||||||||||||||
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"))); | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: Default Parameter Inclusion BugThe |
||||||||||||||||||||||||||||||||||
params | ||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
szokeasaurusrex marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||
impl RegionSpecificApi<'_> { | ||||||||||||||||||||||||||||||||||
fn request(&self, method: Method, url: &str) -> ApiResult<ApiRequest> { | ||||||||||||||||||||||||||||||||||
self.api | ||||||||||||||||||||||||||||||||||
|
@@ -2343,7 +2438,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>, | ||||||||||||||||||||||||||||||||||
|
@@ -2377,7 +2472,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 +2496,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>, | ||||||||||||||||||||||||||||||||||
} |
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,131 @@ | ||||||||
use anyhow::Result; | ||||||||
use clap::Args; | ||||||||
|
||||||||
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<usize, String> { | ||||||||
let value = s | ||||||||
.parse::<usize>() | ||||||||
.map_err(|_| "invalid number".to_owned())?; | ||||||||
if value == 0 { | ||||||||
Err("max-rows must be greater than 0".to_owned()) | ||||||||
} else { | ||||||||
Ok(value) | ||||||||
} | ||||||||
} | ||||||||
shellmayr marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
|
||||||||
/// Fields to fetch from the logs API | ||||||||
const LOG_FIELDS: &[&str] = &[ | ||||||||
"sentry.item_id", | ||||||||
"trace", | ||||||||
"severity", | ||||||||
"timestamp", | ||||||||
"message", | ||||||||
]; | ||||||||
|
||||||||
/// Arguments for listing logs | ||||||||
#[derive(Args)] | ||||||||
pub(super) struct ListLogsArgs { | ||||||||
#[arg(short = 'o', long = "org")] | ||||||||
#[arg(help = "The organization ID or slug.")] | ||||||||
org: Option<String>, | ||||||||
|
||||||||
#[arg(short = 'p', long = "project")] | ||||||||
#[arg(help = "The project ID (slug not supported).")] | ||||||||
project: Option<String>, | ||||||||
|
||||||||
#[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).")] | ||||||||
shellmayr marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
max_rows: usize, | ||||||||
|
||||||||
#[arg(long = "query", default_value = "")] | ||||||||
#[arg(help = "Query to filter logs. Example: \"level:error\"")] | ||||||||
query: String, | ||||||||
} | ||||||||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
|
||||||||
pub(super) fn execute(args: ListLogsArgs) -> Result<()> { | ||||||||
let config = Config::current(); | ||||||||
let (default_org, default_project) = config.get_org_and_project_defaults(); | ||||||||
|
||||||||
let org = args | ||||||||
.org | ||||||||
.as_ref() | ||||||||
.or(default_org.as_ref()) | ||||||||
.ok_or_else(|| { | ||||||||
anyhow::anyhow!("No organization specified. Please specify an organization using the --org argument.") | ||||||||
})? | ||||||||
.to_owned(); | ||||||||
shellmayr marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
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(); | ||||||||
shellmayr marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
|
||||||||
let api = Api::current(); | ||||||||
|
||||||||
let query = if args.query.is_empty() { | ||||||||
None | ||||||||
} else { | ||||||||
Some(args.query.as_str()) | ||||||||
}; | ||||||||
|
||||||||
execute_single_fetch(&api, &org, &project, query, LOG_FIELDS, &args) | ||||||||
} | ||||||||
|
||||||||
fn execute_single_fetch( | ||||||||
api: &Api, | ||||||||
org: &str, | ||||||||
project: &str, | ||||||||
query: Option<&str>, | ||||||||
fields: &[&str], | ||||||||
args: &ListLogsArgs, | ||||||||
) -> Result<()> { | ||||||||
let options = FetchEventsOptions { | ||||||||
dataset: Dataset::OurLogs, | ||||||||
fields, | ||||||||
project_id: Some(project), | ||||||||
cursor: None, | ||||||||
query, | ||||||||
per_page: Some(args.max_rows), | ||||||||
stats_period: Some("1h"), | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @shellmayr, is the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @szokeasaurusrex I think it defaults to 14 days if not explicitly set There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Very wise |
||||||||
sort: Some("-timestamp"), | ||||||||
}; | ||||||||
|
||||||||
let logs = api | ||||||||
.authenticated()? | ||||||||
.fetch_organization_events(org, &options)?; | ||||||||
|
||||||||
let mut table = Table::new(); | ||||||||
table | ||||||||
.title_row() | ||||||||
.add("Item ID") | ||||||||
.add("Timestamp") | ||||||||
.add("Severity") | ||||||||
.add("Message") | ||||||||
.add("Trace"); | ||||||||
vgrozdanic marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
|
||||||||
let logs_to_show = &logs[..args.max_rows.min(logs.len())]; | ||||||||
for log in logs_to_show { | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's please rewrite this way, it is easier to read (this is what I meant with my
Suggested change
|
||||||||
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("")); | ||||||||
} | ||||||||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
|
||||||||
if table.is_empty() { | ||||||||
println!("No logs found"); | ||||||||
} else { | ||||||||
table.print(); | ||||||||
} | ||||||||
|
||||||||
Ok(()) | ||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
mod list; | ||
|
||
use self::list::ListLogsArgs; | ||
use super::derive_parser::{SentryCLI, SentryCLICommand}; | ||
use anyhow::Result; | ||
use clap::ArgMatches; | ||
use clap::{Args, Command, Parser as _, Subcommand}; | ||
|
||
const LIST_ABOUT: &str = "List logs from your organization"; | ||
|
||
#[derive(Args)] | ||
pub(super) struct LogsArgs { | ||
#[command(subcommand)] | ||
subcommand: LogsSubcommand, | ||
} | ||
|
||
#[derive(Subcommand)] | ||
#[command(about = "Manage logs in Sentry")] | ||
#[command(long_about = "Manage and query logs in Sentry. \ | ||
This command provides access to log entries.")] | ||
enum LogsSubcommand { | ||
#[command(about = LIST_ABOUT)] | ||
#[command(long_about = format!("{LIST_ABOUT}. \ | ||
Query and filter log entries from your Sentry projects. \ | ||
Supports filtering by time period, log level, and custom queries."))] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Filtering by time period does not seem to be possible |
||
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), | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.