-
-
Notifications
You must be signed in to change notification settings - Fork 245
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 12 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.
Outdated
|
||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| 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.
Outdated
|
||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
shellmayr marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
vgrozdanic marked this conversation as 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.
Outdated
|
||||||||||||||||||||||||||||||||||
| /// Cursor for pagination | ||||||||||||||||||||||||||||||||||
| pub cursor: Option<&'a str>, | ||||||||||||||||||||||||||||||||||
|
szokeasaurusrex marked this conversation as 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>, | ||||||||||||||||||||||||||||||||||
|
Member
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
Member
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?
Member
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
Member
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.
|
||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| 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,132 @@ | ||
| use anyhow::Result; | ||
| use clap::Args; | ||
|
|
||
| use crate::api::{Api, Dataset, FetchEventsOptions}; | ||
| use crate::config::Config; | ||
| use crate::utils::formatting::Table; | ||
|
|
||
| const MAX_ROWS_RANGE: std::ops::RangeInclusive<usize> = 1..=1000; | ||
| /// Validate that max_rows is in the allowed range | ||
| fn validate_max_rows(s: &str) -> Result<usize> { | ||
| let value = s.parse()?; | ||
| if MAX_ROWS_RANGE.contains(&value) { | ||
| Ok(value) | ||
| } else { | ||
| Err(anyhow::anyhow!( | ||
| "max-rows must be between {} and {}", | ||
| MAX_ROWS_RANGE.start(), | ||
| MAX_ROWS_RANGE.end() | ||
| )) | ||
| } | ||
| } | ||
|
|
||
| /// Fields to fetch from the logs API | ||
| const LOG_FIELDS: &[&str] = &[ | ||
| "sentry.item_id", | ||
| "trace", | ||
| "severity", | ||
| "timestamp", | ||
| "message", | ||
| ]; | ||
|
|
||
| /// Arguments for listing logs | ||
| #[derive(Args)] | ||
| pub(super) struct ListLogsArgs { | ||
| #[arg(short = 'o', long = "org")] | ||
| #[arg(help = "The organization ID or slug.")] | ||
| org: Option<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 = format!("Maximum number of log entries to fetch and display (max {}).", MAX_ROWS_RANGE.end()))] | ||
| max_rows: usize, | ||
|
|
||
| #[arg(long = "query", default_value = "")] | ||
| #[arg(help = "Query to filter logs. Example: \"level:error\"")] | ||
| query: String, | ||
| } | ||
|
cursor[bot] marked this conversation as 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.") | ||
| })?; | ||
|
|
||
| let project = args | ||
| .project | ||
| .as_ref() | ||
| .or(default_project.as_ref()) | ||
| .ok_or_else(|| { | ||
| anyhow::anyhow!("No project specified. Use --project or set a default in config.") | ||
| })?; | ||
|
|
||
| let api = Api::current(); | ||
|
|
||
| let query = if args.query.is_empty() { | ||
| None | ||
| } else { | ||
| Some(args.query.as_str()) | ||
| }; | ||
|
|
||
| execute_single_fetch(&api, org, project, query, LOG_FIELDS, &args) | ||
| } | ||
|
|
||
| fn execute_single_fetch( | ||
| api: &Api, | ||
| org: &str, | ||
| project: &str, | ||
| query: Option<&str>, | ||
| fields: &[&str], | ||
| args: &ListLogsArgs, | ||
| ) -> Result<()> { | ||
| let options = FetchEventsOptions { | ||
| dataset: Dataset::OurLogs, | ||
| fields, | ||
| project_id: Some(project), | ||
| cursor: None, | ||
| query, | ||
| per_page: Some(args.max_rows), | ||
| stats_period: Some("90d"), | ||
| 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.
|
||
|
|
||
| for log in logs.iter().take(args.max_rows) { | ||
| let row = table.add_row(); | ||
| row.add(&log.item_id) | ||
| .add(&log.timestamp) | ||
| .add(log.severity.as_deref().unwrap_or("")) | ||
| .add(log.message.as_deref().unwrap_or("")) | ||
| .add(log.trace.as_deref().unwrap_or("")); | ||
| } | ||
|
cursor[bot] marked this conversation as 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 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), | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.