diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 70fe3a8..92f2539 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -111,6 +111,7 @@ pup slos get abc-123-def ```bash pup logs search --query="status:error" --from="1h" pup logs search --query="service:api" --from="7d" --storage="flex" +pup logs query --query="service:api" --index="main,security" --from="1h" pup dbm samples search --query="dbm_type:activity service:orders env:prod" --from="1h" --limit=10 pup metrics search --query="avg:system.cpu.user{*}" --from="1h" pup metrics query --query="avg:system.cpu.user{*}" --from="1h" diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 9e4e69e..c2c2a42 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -125,6 +125,10 @@ pup logs search \ --query="service:api" \ --from="2024-02-04T10:00:00Z" \ --to="2024-02-04T11:00:00Z" + +# Search specific indexes (comma-separated or repeated) +pup logs query --query="service:api" --index="main,security" --from="1h" +pup logs query --query="service:api" --index="main" --index="security" --from="1h" ``` ### Aggregate Logs @@ -156,6 +160,14 @@ pup logs aggregate \ --from="1h" \ --compute="count,avg(@duration),percentile(@duration, 95)" \ --group-by="service,status" + +# Aggregate a specific index +pup logs aggregate \ + --query="service:web-app" \ + --index="main" \ + --from="1h" \ + --compute="count" \ + --group-by="status" ``` ### Search Logs in Specific Storage Tier diff --git a/docs/LLM_GUIDE.md b/docs/LLM_GUIDE.md index cfc7ca4..1fffd15 100644 --- a/docs/LLM_GUIDE.md +++ b/docs/LLM_GUIDE.md @@ -130,6 +130,9 @@ pup logs aggregate --query="env:prod" --from=30m --compute="percentile(@duration # Storage tiers pup logs search --query="*" --from=30d --storage="flex" + +# Specific indexes +pup logs query --query="*" --index="main,security" --from=1h ``` ### Metrics diff --git a/src/commands/logs.rs b/src/commands/logs.rs index a4e506b..28c7780 100644 --- a/src/commands/logs.rs +++ b/src/commands/logs.rs @@ -19,10 +19,21 @@ pub struct AggregateArgs { pub compute: Vec, pub group_by: Vec, pub limit: i32, + pub index: Vec, pub storage: Option, pub sort: String, } +pub struct SearchArgs { + pub query: String, + pub from: String, + pub to: String, + pub limit: i32, + pub sort: String, + pub storage: Option, + pub index: Vec, +} + fn normalize_storage_tier(storage: Option) -> Result> { match storage { None => Ok(None), @@ -120,6 +131,7 @@ fn build_aggregate_body( computes: Vec, group_bys: Vec, limit: i32, + index: Vec, storage: Option, sort: &str, ) -> Result { @@ -130,6 +142,9 @@ fn build_aggregate_body( "from": from_ms.to_string(), "to": to_ms.to_string() }); + if !index.is_empty() { + filter["indexes"] = serde_json::json!(index); + } if let Some(tier) = storage_tier { filter["storage_tier"] = serde_json::Value::String(tier); } @@ -176,15 +191,16 @@ fn parse_logs_sort(sort: &str) -> LogsSort { } } -pub async fn search( - cfg: &Config, - query: String, - from: String, - to: String, - limit: i32, - sort: String, - storage: Option, -) -> Result<()> { +pub async fn search(cfg: &Config, args: SearchArgs) -> Result<()> { + let SearchArgs { + query, + from, + to, + limit, + sort, + storage, + index, + } = args; let api = crate::make_api!(LogsAPI, cfg); let from_ms = util::parse_time_to_unix_millis(&from)?; @@ -196,6 +212,9 @@ pub async fn search( .query(query) .from(from_ms.to_string()) .to(to_ms.to_string()); + if !index.is_empty() { + filter = filter.indexes(index); + } if let Some(tier) = storage_tier { filter = filter.storage_tier(tier); } @@ -242,29 +261,13 @@ pub async fn search( } /// Alias for `search` with the same interface. -pub async fn list( - cfg: &Config, - query: String, - from: String, - to: String, - limit: i32, - sort: String, - storage: Option, -) -> Result<()> { - search(cfg, query, from, to, limit, sort, storage).await +pub async fn list(cfg: &Config, args: SearchArgs) -> Result<()> { + search(cfg, args).await } /// Alias for `search` with the same interface. -pub async fn query( - cfg: &Config, - query: String, - from: String, - to: String, - limit: i32, - sort: String, - storage: Option, -) -> Result<()> { - search(cfg, query, from, to, limit, sort, storage).await +pub async fn query(cfg: &Config, args: SearchArgs) -> Result<()> { + search(cfg, args).await } pub async fn aggregate(cfg: &Config, args: AggregateArgs) -> Result<()> { @@ -275,6 +278,7 @@ pub async fn aggregate(cfg: &Config, args: AggregateArgs) -> Result<()> { mut compute, group_by, limit, + index, storage, sort, } = args; @@ -284,7 +288,7 @@ pub async fn aggregate(cfg: &Config, args: AggregateArgs) -> Result<()> { let from_ms = util::parse_time_to_unix_millis(&from)?; let to_ms = util::parse_time_to_unix_millis(&to)?; let body = build_aggregate_body( - query, from_ms, to_ms, compute, group_by, limit, storage, &sort, + query, from_ms, to_ms, compute, group_by, limit, index, storage, &sort, )?; let data = client::raw_post(cfg, "/api/v2/logs/analytics/aggregate", body).await?; formatter::output(cfg, &data)?; @@ -407,6 +411,18 @@ mod tests { use super::*; + fn search_args(query: &str, storage: Option, index: Vec) -> SearchArgs { + SearchArgs { + query: query.into(), + from: "1h".into(), + to: "now".into(), + limit: 10, + sort: "-timestamp".into(), + storage, + index, + } + } + #[test] fn test_normalize_storage_tier_alias() { let tier = normalize_storage_tier(Some("online_archives".into())).unwrap(); @@ -422,6 +438,7 @@ mod tests { vec!["avg(@duration)".into()], vec!["service".into()], 3, + vec![], Some("flex".into()), "count", ) @@ -462,6 +479,7 @@ mod tests { vec!["count".into()], vec![], 10, + vec![], None, "count", ) @@ -495,6 +513,7 @@ mod tests { ], vec![], 10, + vec![], None, "count", ) @@ -526,6 +545,7 @@ mod tests { vec!["count".into()], vec!["service".into(), "status".into()], 5, + vec![], None, "count", ) @@ -585,6 +605,7 @@ mod tests { vec!["count".into()], vec!["host".into()], 10, + vec![], None, "pc95", ) @@ -609,6 +630,7 @@ mod tests { vec!["count".into()], vec![], 10, + vec![], None, "pc95", ) @@ -617,6 +639,45 @@ mod tests { assert!(body.get("group_by").is_none()); } + #[test] + fn test_build_aggregate_body_omits_empty_indexes() { + let body = build_aggregate_body( + "*".into(), + 1, + 2, + vec!["count".into()], + vec![], + 10, + vec![], + None, + "count", + ) + .unwrap(); + + assert!(body["filter"].get("indexes").is_none()); + } + + #[test] + fn test_build_aggregate_body_includes_indexes() { + let body = build_aggregate_body( + "*".into(), + 1, + 2, + vec!["count".into()], + vec![], + 10, + vec!["main".into(), "web".into()], + None, + "count", + ) + .unwrap(); + + assert_eq!( + body["filter"]["indexes"], + serde_json::json!(["main", "web"]) + ); + } + #[test] fn test_split_compute_args_single() { assert_eq!(split_compute_args("count"), vec!["count"]); @@ -658,17 +719,38 @@ mod tests { let cfg = test_config(&server.url()); let _mock = mock_any(&mut server, "POST", r#"{"data": [], "meta": {"page": {}}}"#).await; + let result = super::search(&cfg, search_args("status:error", None, vec![])).await; + assert!(result.is_ok(), "logs search failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_logs_search_with_indexes() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = server + .mock("POST", mockito::Matcher::Any) + .match_query(mockito::Matcher::Any) + .match_body(mockito::Matcher::Regex( + r#""indexes":\["main","web"\]"#.to_string(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"data": [], "meta": {"page": {}}}"#) + .create_async() + .await; + let result = super::search( &cfg, - "status:error".into(), - "1h".into(), - "now".into(), - 10, - "-timestamp".into(), - None, + search_args("*", None, vec!["main".into(), "web".into()]), ) .await; - assert!(result.is_ok(), "logs search failed: {:?}", result.err()); + assert!( + result.is_ok(), + "logs search with indexes failed: {:?}", + result.err() + ); cleanup_env(); } @@ -694,16 +776,7 @@ mod tests { let _mock = mock_any(&mut server, "POST", r#"{"data": []}"#).await; - let result = super::search( - &cfg, - "status:error".into(), - "1h".into(), - "now".into(), - 10, - "-timestamp".into(), - None, - ) - .await; + let result = super::search(&cfg, search_args("status:error", None, vec![])).await; assert!(result.is_ok(), "logs search should work with OAuth"); cleanup_env(); } @@ -724,6 +797,7 @@ mod tests { compute: vec!["count".into()], group_by: vec![], limit: 10, + index: vec![], storage: None, sort: "count".into(), }, @@ -751,6 +825,7 @@ mod tests { ), group_by: vec!["service".into(), "status".into()], limit: 10, + index: vec![], storage: None, sort: "count".into(), }, @@ -771,16 +846,7 @@ mod tests { let cfg = test_config(&server.url()); let _mock = mock_any(&mut server, "POST", r#"{"data": [], "meta": {"page": {}}}"#).await; - let result = super::search( - &cfg, - "*".into(), - "1h".into(), - "now".into(), - 10, - "-timestamp".into(), - Some("flex".into()), - ) - .await; + let result = super::search(&cfg, search_args("*", Some("flex".into()), vec![])).await; assert!( result.is_ok(), "logs search with flex failed: {:?}", @@ -798,12 +864,7 @@ mod tests { let result = super::search( &cfg, - "*".into(), - "1h".into(), - "now".into(), - 10, - "-timestamp".into(), - Some("online-archives".into()), + search_args("*", Some("online-archives".into()), vec![]), ) .await; assert!( @@ -820,16 +881,8 @@ mod tests { let server = mockito::Server::new_async().await; let cfg = test_config(&server.url()); - let result = super::search( - &cfg, - "*".into(), - "1h".into(), - "now".into(), - 10, - "-timestamp".into(), - Some("invalid-tier".into()), - ) - .await; + let result = + super::search(&cfg, search_args("*", Some("invalid-tier".into()), vec![])).await; assert!( result.is_err(), "logs search with invalid storage tier should fail" @@ -860,6 +913,7 @@ mod tests { compute: vec!["count".into()], group_by: vec![], limit: 10, + index: vec![], storage: Some("flex".into()), sort: "count".into(), }, diff --git a/src/main.rs b/src/main.rs index 693d819..a07ed17 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3068,8 +3068,12 @@ enum LogActions { limit: i32, #[arg(long, help = "Sort order: asc or desc", default_value = "desc")] sort: String, - #[arg(long, help = "Comma-separated log indexes")] - index: Option, + #[arg( + long, + value_delimiter = ',', + help = "Log indexes to aggregate, comma-separated or repeated" + )] + index: Vec, #[arg(long, help = "Storage tier: indexes, online-archives, or flex")] storage: Option, }, @@ -3096,6 +3100,12 @@ enum LogActions { sort: String, #[arg(long, help = "Storage tier: indexes, online-archives, or flex")] storage: Option, + #[arg( + long, + value_delimiter = ',', + help = "Log indexes to search, comma-separated or repeated" + )] + index: Vec, }, /// Query logs (v2 API) Query { @@ -3120,6 +3130,12 @@ enum LogActions { sort: String, #[arg(long, help = "Storage tier: indexes, online-archives, or flex")] storage: Option, + #[arg( + long, + value_delimiter = ',', + help = "Log indexes to search, comma-separated or repeated" + )] + index: Vec, #[arg(long, help = "Timezone for timestamps")] timezone: Option, }, @@ -3150,6 +3166,12 @@ enum LogActions { limit: i32, #[arg(long, help = "Storage tier: indexes, online-archives, or flex")] storage: Option, + #[arg( + long, + value_delimiter = ',', + help = "Log indexes to search, comma-separated or repeated" + )] + index: Vec, #[arg( long, allow_hyphen_values = true, @@ -11570,10 +11592,22 @@ async fn main_inner() -> anyhow::Result<()> { to, limit, sort, - index: _, + index, storage, } => { - commands::logs::search(&cfg, query, from, to, limit, sort, storage).await?; + commands::logs::search( + &cfg, + commands::logs::SearchArgs { + query, + from, + to, + limit, + sort, + storage, + index, + }, + ) + .await?; } LogActions::List { query, @@ -11582,8 +11616,21 @@ async fn main_inner() -> anyhow::Result<()> { limit, sort, storage, + index, } => { - commands::logs::list(&cfg, query, from, to, limit, sort, storage).await?; + commands::logs::list( + &cfg, + commands::logs::SearchArgs { + query, + from, + to, + limit, + sort, + storage, + index, + }, + ) + .await?; } LogActions::Query { query, @@ -11592,9 +11639,22 @@ async fn main_inner() -> anyhow::Result<()> { limit, sort, storage, + index, timezone: _, } => { - commands::logs::query(&cfg, query, from, to, limit, sort, storage).await?; + commands::logs::query( + &cfg, + commands::logs::SearchArgs { + query, + from, + to, + limit, + sort, + storage, + index, + }, + ) + .await?; } LogActions::Aggregate { query, @@ -11604,6 +11664,7 @@ async fn main_inner() -> anyhow::Result<()> { group_by, limit, storage, + index, sort, } => { commands::logs::aggregate( @@ -11622,6 +11683,7 @@ async fn main_inner() -> anyhow::Result<()> { }) .unwrap_or_default(), limit, + index, storage, sort, },