diff --git a/skills/apitally-cli/SKILL.md b/skills/apitally-cli/SKILL.md index 94cc114..1cef2a0 100644 --- a/skills/apitally-cli/SKILL.md +++ b/skills/apitally-cli/SKILL.md @@ -43,7 +43,7 @@ All commands are run via `npx @apitally/cli `. For full details, see [r - `consumers [--requests-since
] [--db []]` -- list consumers for an app (get consumer IDs) - `endpoints [--method ] [--path ] [--db []]` -- list endpoints for an app - `metrics --since
[--until
] --metrics [--interval ] [--group-by ] [--filters ] [--timezone ] [--db []]` -- fetch aggregated metrics -- `request-logs --since
[--until
] [--fields ] [--filters ] [--limit ] [--db []]` -- fetch request logs (max 1,000,000 rows at once) +- `request-logs --since
[--until
] [--fields ] [--filters ] [--sample ] [--limit ] [--db []]` -- fetch request logs (max 1,000,000 rows at once) - `request-details [--db []]` -- fetch full details for a single request (including headers, payloads, exception info, application logs, and spans) - `sql "" [--db ]` -- run SQL against local DuckDB - `reset-db [--db ]` -- drop and recreate all tables in local DuckDB diff --git a/skills/apitally-cli/references/commands.md b/skills/apitally-cli/references/commands.md index c9ccdca..58b5f96 100644 --- a/skills/apitally-cli/references/commands.md +++ b/skills/apitally-cli/references/commands.md @@ -150,7 +150,7 @@ Example NDJSON output (without `--db`): ``` npx @apitally/cli request-logs --since \ [--until ] [--fields ] [--filters ] \ - [--limit ] [--db []] + [--sample ] [--limit ] [--db []] ``` Fetch request log data for an app. Outputs NDJSON to stdout by default. @@ -159,6 +159,7 @@ Fetch request log data for an app. Outputs NDJSON to stdout by default. - `--until`: End of time range, exclusive (ISO 8601, defaults to now) - `--fields`: JSON array of field names to include - `--filters`: JSON array of filter objects +- `--sample`: Approximate sample size (integer, e.g. `1000`) or sample rate (float > 0 and <= 0.5, e.g. `0.1` for ~10%) - `--limit`: Maximum number of rows (hard cap: 1,000,000) - `--db`: Write to `request_logs` table in DuckDB instead of outputting NDJSON to stdout diff --git a/src/main.rs b/src/main.rs index b252ae3..abd85cd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -249,6 +249,14 @@ enum Command { #[arg(long)] filters: Option, + /// Approximate sample size (integer) or sample rate (float > 0 and <= 0.5) + /// + /// Pass an integer for size-based sampling (e.g. 1000 for ~1000 rows). + /// Pass a float between 0 (exclusive) and 0.5 (inclusive) for rate-based + /// sampling (e.g. 0.1 for ~10% of rows). + #[arg(long)] + sample: Option, + /// Maximum number of rows to return #[arg(long)] limit: Option, @@ -428,6 +436,7 @@ fn run(cli: Cli) -> Result<()> { until, fields, filters, + sample, limit, db, } => { @@ -438,6 +447,7 @@ fn run(cli: Cli) -> Result<()> { until.as_deref(), fields.as_deref(), filters.as_deref(), + sample.as_deref(), limit, db.as_deref(), api.api_key.as_deref(), diff --git a/src/request_logs.rs b/src/request_logs.rs index b0fe771..20f8d6a 100644 --- a/src/request_logs.rs +++ b/src/request_logs.rs @@ -47,6 +47,7 @@ pub fn run( until: Option<&str>, fields: Option<&str>, filters: Option<&str>, + sample: Option<&str>, limit: Option, db: Option<&Path>, api_key: Option<&str>, @@ -75,6 +76,23 @@ pub fn run( .map_err(|e| input_err(format!("invalid JSON for --filters: {e}")))?; body["filters"] = filters_value; } + if let Some(sample) = sample { + if let Ok(n) = sample.parse::() { + if n < 1 { + return Err(input_err("--sample as integer must be greater than 0")); + } + body["sample"] = serde_json::json!(n); + } else if let Ok(f) = sample.parse::() { + if !f.is_finite() || f <= 0.0 || f > 0.5 { + return Err(input_err( + "--sample as float must be between 0 (exclusive) and 0.5 (inclusive)", + )); + } + body["sample"] = serde_json::json!(f); + } else { + return Err(input_err("--sample must be an integer or float")); + } + } if let Some(limit) = limit { body["limit"] = serde_json::json!(limit); } @@ -246,6 +264,7 @@ mod tests { Some("2025-01-02"), Some(r#"["method","url","status_code"]"#), Some(r#"{"status_code":200}"#), + None, Some(10), None, Some("test-key"), @@ -263,6 +282,37 @@ mod tests { assert_eq!(rows[1]["url"], "https://api.example.com/test2"); } + #[test] + fn test_run_ndjson_with_sample() { + for (sample, expected_json) in + [("500", r#"{"sample":500}"#), ("0.25", r#"{"sample":0.25}"#)] + { + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/v1/apps/1/request-logs") + .match_body(mockito::Matcher::PartialJsonString(expected_json.into())) + .with_status(200) + .with_body(sample_request_logs_ndjson()) + .create(); + + run( + 1, + "2025-01-01", + None, + None, + None, + Some(sample), + None, + None, + Some("test-key"), + Some(&server.url()), + &mut Vec::new(), + ) + .unwrap(); + mock.assert(); + } + } + #[test] fn test_run_with_db() { let mut server = mockito::Server::new(); @@ -276,6 +326,7 @@ mod tests { None, None, None, + None, Some(&db_path), Some("test-key"), Some(&server.url()),