Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion skills/apitally-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ All commands are run via `npx @apitally/cli <command>`. For full details, see [r
- `consumers <app-id> [--requests-since <dt>] [--db [<path>]]` -- list consumers for an app (get consumer IDs)
- `endpoints <app-id> [--method <methods>] [--path <pattern>] [--db [<path>]]` -- list endpoints for an app
- `metrics <app-id> --since <dt> [--until <dt>] --metrics <json> [--interval <interval>] [--group-by <json>] [--filters <json>] [--timezone <tz>] [--db [<path>]]` -- fetch aggregated metrics
- `request-logs <app-id> --since <dt> [--until <dt>] [--fields <json>] [--filters <json>] [--limit <n>] [--db [<path>]]` -- fetch request logs (max 1,000,000 rows at once)
- `request-logs <app-id> --since <dt> [--until <dt>] [--fields <json>] [--filters <json>] [--sample <n|rate>] [--limit <n>] [--db [<path>]]` -- fetch request logs (max 1,000,000 rows at once)
- `request-details <app-id> <request-uuid> [--db [<path>]]` -- fetch full details for a single request (including headers, payloads, exception info, application logs, and spans)
- `sql "<query>" [--db <path>]` -- run SQL against local DuckDB
- `reset-db [--db <path>]` -- drop and recreate all tables in local DuckDB
Expand Down
3 changes: 2 additions & 1 deletion skills/apitally-cli/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ Example NDJSON output (without `--db`):
```
npx @apitally/cli request-logs <app-id> --since <datetime> \
[--until <datetime>] [--fields <json>] [--filters <json>] \
[--limit <n>] [--db [<path>]]
[--sample <n|rate>] [--limit <n>] [--db [<path>]]
```

Fetch request log data for an app. Outputs NDJSON to stdout by default.
Expand All @@ -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

Expand Down
10 changes: 10 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,14 @@ enum Command {
#[arg(long)]
filters: Option<String>,

/// 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<String>,

/// Maximum number of rows to return
#[arg(long)]
limit: Option<i64>,
Expand Down Expand Up @@ -428,6 +436,7 @@ fn run(cli: Cli) -> Result<()> {
until,
fields,
filters,
sample,
limit,
db,
} => {
Expand All @@ -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(),
Expand Down
51 changes: 51 additions & 0 deletions src/request_logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pub fn run(
until: Option<&str>,
fields: Option<&str>,
filters: Option<&str>,
sample: Option<&str>,
limit: Option<i64>,
db: Option<&Path>,
api_key: Option<&str>,
Expand Down Expand Up @@ -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::<i64>() {
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::<f64>() {
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);
}
Expand Down Expand Up @@ -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"),
Expand All @@ -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();
Expand All @@ -276,6 +326,7 @@ mod tests {
None,
None,
None,
None,
Some(&db_path),
Some("test-key"),
Some(&server.url()),
Expand Down
Loading