From b05be419ca55591c34ec0c90abc71052f1f7da1f Mon Sep 17 00:00:00 2001
From: Simon Gurcke <simon@gurcke.de>
Date: Thu, 16 Apr 2026 13:13:47 +1000
Subject: [PATCH 1/3] Add `--sample` arg to `request-logs` command

---
 skills/apitally-cli/SKILL.md               |  2 +-
 skills/apitally-cli/references/commands.md |  3 ++-
 src/main.rs                                | 10 ++++++++++
 src/request_logs.rs                        | 20 ++++++++++++++++++++
 4 files changed, 33 insertions(+), 2 deletions(-)

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 <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
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 <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.
@@ -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<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>,
@@ -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..b431b59 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<i64>,
     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::<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 <= 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"),
@@ -276,6 +295,7 @@ mod tests {
             None,
             None,
             None,
+            None,
             Some(&db_path),
             Some("test-key"),
             Some(&server.url()),

From 1b36b812aaface9f76b4eb36274174103808f126 Mon Sep 17 00:00:00 2001
From: Simon Gurcke <simon@gurcke.de>
Date: Thu, 16 Apr 2026 13:38:27 +1000
Subject: [PATCH 2/3] Update src/request_logs.rs

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
---
 src/request_logs.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/request_logs.rs b/src/request_logs.rs
index b431b59..6e97fde 100644
--- a/src/request_logs.rs
+++ b/src/request_logs.rs
@@ -83,7 +83,7 @@ pub fn run(
             }
             body["sample"] = serde_json::json!(n);
         } else if let Ok(f) = sample.parse::<f64>() {
-            if f <= 0.0 || f > 0.5 {
+            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)",
                 ));

From d20e8bed77e562742ae010a8b091b5c09eb92a08 Mon Sep 17 00:00:00 2001
From: Simon Gurcke <simon@gurcke.de>
Date: Thu, 16 Apr 2026 15:52:03 +1000
Subject: [PATCH 3/3] Add test

---
 src/request_logs.rs | 31 +++++++++++++++++++++++++++++++
 1 file changed, 31 insertions(+)

diff --git a/src/request_logs.rs b/src/request_logs.rs
index 6e97fde..20f8d6a 100644
--- a/src/request_logs.rs
+++ b/src/request_logs.rs
@@ -282,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();
