Skip to content

Commit 33e1310

Browse files
Merge pull request #246 from richardsun0713/rsun-multi-compute-groupby
feat(logs): support multiple --compute and --group-by values
2 parents 56d1917 + f1af99e commit 33e1310

4 files changed

Lines changed: 238 additions & 31 deletions

File tree

docs/EXAMPLES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ pup logs aggregate \
128128
--from="30m" \
129129
--compute="percentile(@duration, 99)" \
130130
--group-by="service"
131+
132+
# Multiple metrics in one query (comma-separated)
133+
pup logs aggregate \
134+
--query="service:web-app" \
135+
--from="1h" \
136+
--compute="count,avg(@duration),percentile(@duration, 95)" \
137+
--group-by="service,status"
131138
```
132139

133140
### Search Logs in Specific Storage Tier

src/commands/logs.rs

Lines changed: 172 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ pub struct AggregateArgs {
2222
pub query: String,
2323
pub from: String,
2424
pub to: String,
25-
pub compute: String,
26-
pub group_by: Option<String>,
25+
pub compute: Vec<String>,
26+
pub group_by: Vec<String>,
2727
pub limit: i32,
2828
pub storage: Option<String>,
2929
}
@@ -56,6 +56,39 @@ fn parse_storage_tier(storage: Option<String>) -> Result<Option<LogsStorageTier>
5656
}
5757
}
5858

59+
/// Split a comma-separated compute string into individual compute expressions,
60+
/// respecting parentheses so that `percentile(@duration, 95)` is not split.
61+
pub fn split_compute_args(input: &str) -> Vec<String> {
62+
let mut result = Vec::new();
63+
let mut current = String::new();
64+
let mut depth = 0u32;
65+
for ch in input.chars() {
66+
match ch {
67+
'(' => {
68+
depth += 1;
69+
current.push(ch);
70+
}
71+
')' => {
72+
depth = depth.saturating_sub(1);
73+
current.push(ch);
74+
}
75+
',' if depth == 0 => {
76+
let trimmed = current.trim().to_string();
77+
if !trimmed.is_empty() {
78+
result.push(trimmed);
79+
}
80+
current.clear();
81+
}
82+
_ => current.push(ch),
83+
}
84+
}
85+
let trimmed = current.trim().to_string();
86+
if !trimmed.is_empty() {
87+
result.push(trimmed);
88+
}
89+
result
90+
}
91+
5992
fn parse_compute_raw(input: &str) -> Result<(String, Option<String>)> {
6093
let input = input.trim();
6194
if input.is_empty() {
@@ -110,12 +143,11 @@ fn build_aggregate_body(
110143
query: String,
111144
from_ms: i64,
112145
to_ms: i64,
113-
compute: String,
114-
group_by: Option<String>,
146+
computes: Vec<String>,
147+
group_bys: Vec<String>,
115148
limit: i32,
116149
storage: Option<String>,
117150
) -> Result<serde_json::Value> {
118-
let (aggregation, metric) = parse_compute_raw(&compute)?;
119151
let storage_tier = normalize_storage_tier(storage)?;
120152

121153
let mut filter = serde_json::json!({
@@ -127,22 +159,35 @@ fn build_aggregate_body(
127159
filter["storage_tier"] = serde_json::Value::String(tier);
128160
}
129161

130-
let mut compute_obj = serde_json::json!({ "aggregation": aggregation });
131-
if let Some(metric) = metric {
132-
compute_obj["metric"] = serde_json::Value::String(metric);
133-
}
162+
let compute_arr: Vec<serde_json::Value> = computes
163+
.iter()
164+
.map(|c| {
165+
let (aggregation, metric) = parse_compute_raw(c)?;
166+
let mut obj = serde_json::json!({ "aggregation": aggregation });
167+
if let Some(m) = metric {
168+
obj["metric"] = serde_json::Value::String(m);
169+
}
170+
Ok(obj)
171+
})
172+
.collect::<Result<Vec<_>>>()?;
134173

135174
let mut body = serde_json::json!({
136175
"filter": filter,
137-
"compute": [compute_obj]
176+
"compute": compute_arr
138177
});
139178

140-
if let Some(facet) = group_by {
141-
let mut group_by_obj = serde_json::json!({ "facet": facet });
142-
if limit > 0 {
143-
group_by_obj["limit"] = serde_json::json!(limit);
144-
}
145-
body["group_by"] = serde_json::json!([group_by_obj]);
179+
if !group_bys.is_empty() {
180+
let group_by_arr: Vec<serde_json::Value> = group_bys
181+
.iter()
182+
.map(|facet| {
183+
let mut obj = serde_json::json!({ "facet": facet });
184+
if limit > 0 {
185+
obj["limit"] = serde_json::json!(limit);
186+
}
187+
obj
188+
})
189+
.collect();
190+
body["group_by"] = serde_json::json!(group_by_arr);
146191
}
147192

148193
Ok(body)
@@ -285,11 +330,14 @@ pub async fn aggregate(cfg: &Config, args: AggregateArgs) -> Result<()> {
285330
query,
286331
from,
287332
to,
288-
compute,
333+
mut compute,
289334
group_by,
290335
limit,
291336
storage,
292337
} = args;
338+
if compute.is_empty() {
339+
compute.push("count".into());
340+
}
293341
let from_ms = util::parse_time_to_unix_millis(&from)?;
294342
let to_ms = util::parse_time_to_unix_millis(&to)?;
295343
let body = build_aggregate_body(query, from_ms, to_ms, compute, group_by, limit, storage)?;
@@ -304,11 +352,14 @@ pub async fn aggregate(cfg: &Config, args: AggregateArgs) -> Result<()> {
304352
query,
305353
from,
306354
to,
307-
compute,
355+
mut compute,
308356
group_by,
309357
limit,
310358
storage,
311359
} = args;
360+
if compute.is_empty() {
361+
compute.push("count".into());
362+
}
312363
let from_ms = util::parse_time_to_unix_millis(&from)?;
313364
let to_ms = util::parse_time_to_unix_millis(&to)?;
314365
let body = build_aggregate_body(query, from_ms, to_ms, compute, group_by, limit, storage)?;
@@ -626,7 +677,7 @@ mod tests {
626677

627678
#[test]
628679
fn test_parse_compute_unsupported_percentile() {
629-
assert!(parse_compute_raw("percentile(@duration, 50)").is_err());
680+
assert!(parse_compute_raw("percentile(@duration, 42)").is_err());
630681
}
631682

632683
#[test]
@@ -652,8 +703,8 @@ mod tests {
652703
"service:web".into(),
653704
1,
654705
2,
655-
"avg(@duration)".into(),
656-
Some("service".into()),
706+
vec!["avg(@duration)".into()],
707+
vec!["service".into()],
657708
3,
658709
Some("flex".into()),
659710
)
@@ -682,7 +733,8 @@ mod tests {
682733

683734
#[test]
684735
fn test_build_aggregate_body_omits_group_by_for_plain_count() {
685-
let body = build_aggregate_body("*".into(), 1, 2, "count".into(), None, 10, None).unwrap();
736+
let body =
737+
build_aggregate_body("*".into(), 1, 2, vec!["count".into()], vec![], 10, None).unwrap();
686738

687739
assert_eq!(
688740
body,
@@ -698,4 +750,102 @@ mod tests {
698750
})
699751
);
700752
}
753+
754+
#[test]
755+
fn test_build_aggregate_body_multiple_computes() {
756+
let body = build_aggregate_body(
757+
"*".into(),
758+
1,
759+
2,
760+
vec![
761+
"count".into(),
762+
"avg(@duration)".into(),
763+
"percentile(@duration, 95)".into(),
764+
],
765+
vec![],
766+
10,
767+
None,
768+
)
769+
.unwrap();
770+
771+
assert_eq!(
772+
body,
773+
serde_json::json!({
774+
"filter": {
775+
"query": "*",
776+
"from": "1",
777+
"to": "2"
778+
},
779+
"compute": [
780+
{ "aggregation": "count" },
781+
{ "aggregation": "avg", "metric": "@duration" },
782+
{ "aggregation": "pc95", "metric": "@duration" }
783+
]
784+
})
785+
);
786+
}
787+
788+
#[test]
789+
fn test_build_aggregate_body_multiple_group_bys() {
790+
let body = build_aggregate_body(
791+
"*".into(),
792+
1,
793+
2,
794+
vec!["count".into()],
795+
vec!["service".into(), "status".into()],
796+
5,
797+
None,
798+
)
799+
.unwrap();
800+
801+
assert_eq!(
802+
body,
803+
serde_json::json!({
804+
"filter": {
805+
"query": "*",
806+
"from": "1",
807+
"to": "2"
808+
},
809+
"compute": [{ "aggregation": "count" }],
810+
"group_by": [
811+
{ "facet": "service", "limit": 5 },
812+
{ "facet": "status", "limit": 5 }
813+
]
814+
})
815+
);
816+
}
817+
818+
#[test]
819+
fn test_split_compute_args_single() {
820+
assert_eq!(split_compute_args("count"), vec!["count"]);
821+
}
822+
823+
#[test]
824+
fn test_split_compute_args_multiple() {
825+
assert_eq!(
826+
split_compute_args("count,avg(@duration),max(@duration)"),
827+
vec!["count", "avg(@duration)", "max(@duration)"]
828+
);
829+
}
830+
831+
#[test]
832+
fn test_split_compute_args_preserves_parens_with_comma() {
833+
assert_eq!(
834+
split_compute_args("count,percentile(@duration, 95)"),
835+
vec!["count", "percentile(@duration, 95)"]
836+
);
837+
}
838+
839+
#[test]
840+
fn test_split_compute_args_trims_whitespace() {
841+
assert_eq!(
842+
split_compute_args(" count , avg(@duration) "),
843+
vec!["count", "avg(@duration)"]
844+
);
845+
}
846+
847+
#[test]
848+
fn test_split_compute_args_empty() {
849+
assert!(split_compute_args("").is_empty());
850+
}
701851
}

src/main.rs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1182,6 +1182,12 @@ enum Commands {
11821182
/// # Aggregate logs by status
11831183
/// pup logs aggregate --query="*" --compute="count" --group-by="status"
11841184
///
1185+
/// # Multiple computes in one query (comma-separated)
1186+
/// pup logs aggregate --query="*" --compute="count,avg(@duration),percentile(@duration, 95)"
1187+
///
1188+
/// # Multiple group-by dimensions (comma-separated)
1189+
/// pup logs aggregate --query="*" --compute="count" --group-by="service,status"
1190+
///
11851191
/// # List log archives
11861192
/// pup logs archives list
11871193
///
@@ -2137,11 +2143,18 @@ enum LogActions {
21372143
from: String,
21382144
#[arg(long, default_value = "now", help = "End time")]
21392145
to: String,
2140-
#[arg(long, default_value = "count", help = "Metric to compute")]
2146+
#[arg(
2147+
long,
2148+
default_value = "count",
2149+
help = "Metrics to compute (comma-separated, e.g. count,avg(@duration),percentile(@duration, 95))"
2150+
)]
21412151
compute: String,
2142-
#[arg(long, help = "Field to group by")]
2152+
#[arg(
2153+
long,
2154+
help = "Fields to group by (comma-separated, e.g. service,status)"
2155+
)]
21432156
group_by: Option<String>,
2144-
#[arg(long, default_value_t = 10, help = "Maximum groups")]
2157+
#[arg(long, default_value_t = 10, help = "Maximum groups per facet")]
21452158
limit: i32,
21462159
#[arg(long, help = "Storage tier: indexes, online-archives, or flex")]
21472160
storage: Option<String>,
@@ -6337,8 +6350,15 @@ async fn main_inner() -> anyhow::Result<()> {
63376350
query: query.unwrap_or_default(),
63386351
from,
63396352
to,
6340-
compute,
6341-
group_by,
6353+
compute: commands::logs::split_compute_args(&compute),
6354+
group_by: group_by
6355+
.map(|g| {
6356+
g.split(',')
6357+
.map(|s| s.trim().to_string())
6358+
.filter(|s| !s.is_empty())
6359+
.collect()
6360+
})
6361+
.unwrap_or_default(),
63426362
limit,
63436363
storage,
63446364
},

0 commit comments

Comments
 (0)