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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ arrow = { version = "=58.1.0", default-features = false, features = [
"chrono-tz",
] }
arrow-ipc = { version = "=58.1.0", features = ["lz4"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
clap = { version = "4", features = ["derive", "env"] }
dirs = "6"
duckdb = { version = "=1.10501.0", features = ["bundled", "appender-arrow"] }
Expand Down
12 changes: 7 additions & 5 deletions skills/apitally-cli/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ All commands accept an `--api-key <key>` flag for authentication (except `sql`).

Commands that accept a `--db` flag use `~/.apitally/data.duckdb` as the default database path if no other path is specified. If the database file doesn't exist, it will be created (except for the `sql` command). When writing to tables, existing records are updated (no duplicates are created).

Datetime flags (e.g. `--since`, `--until`, `--requests-since`) accept ISO 8601 strings or compact relative durations (e.g. `30m`, `24h`, `7d`, `2w`).

## `auth`

```
Expand Down Expand Up @@ -54,7 +56,7 @@ npx @apitally/cli consumers <app-id> [--requests-since <datetime>] [--db [<path>

List all consumers for an app. Use this to map consumer IDs in request logs to identifiers and names. Outputs NDJSON to stdout by default.

- `--requests-since`: Only return consumers active since this datetime (ISO 8601)
- `--requests-since`: Only return consumers active since this datetime (ISO 8601 or relative duration, e.g. 24h, 7d)
- `--db`: Write to `consumers` table in DuckDB instead of outputting NDJSON to stdout

Example NDJSON output (without `--db`):
Expand Down Expand Up @@ -93,8 +95,8 @@ npx @apitally/cli metrics <app-id> --since <datetime> --metrics <json> \

Fetch aggregated metrics for an app. Outputs NDJSON to stdout by default.

- `--since`: Start of time range, inclusive (ISO 8601, required)
- `--until`: End of time range, exclusive (ISO 8601, defaults to now)
- `--since`: Start of time range, inclusive (ISO 8601 or relative duration, required)
- `--until`: End of time range, exclusive (ISO 8601 or relative duration, defaults to now)
- `--metrics`: JSON array of metric names to include (required)
- `--interval`: Time interval for grouping (`month`, `day`, `hour`, `minute`). When omitted, returns a single row per group for the entire time range
- `--group-by`: JSON array of field names to group by, in addition to time period
Expand Down Expand Up @@ -155,8 +157,8 @@ npx @apitally/cli request-logs <app-id> --since <datetime> \

Fetch request log data for an app. Outputs NDJSON to stdout by default.

- `--since`: Start of time range, inclusive (ISO 8601, required)
- `--until`: End of time range, exclusive (ISO 8601, defaults to now)
- `--since`: Start of time range, inclusive (ISO 8601 or relative duration, required)
- `--until`: End of time range, exclusive (ISO 8601 or relative duration, 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%)
Expand Down
5 changes: 3 additions & 2 deletions src/consumers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use anyhow::Result;
use serde::{Deserialize, Serialize};

use crate::auth::{resolve_api_base_url, resolve_api_key};
use crate::utils::{api_get, open_db};
use crate::utils::{api_get, open_db, resolve_relative_datetime};

#[derive(Deserialize)]
struct ConsumersResponse {
Expand Down Expand Up @@ -103,6 +103,7 @@ pub fn run(
let api_key = resolve_api_key(api_key)?;
let api_base_url = resolve_api_base_url(api_base_url);
let db = db.map(|p| open_db(p).map(|c| (p, c))).transpose()?;
let requests_since = requests_since.map(resolve_relative_datetime);

if let Some((_, conn)) = &db {
ensure_consumers_table(conn)?;
Expand All @@ -123,7 +124,7 @@ pub fn run(
&api_key,
&api_base_url,
app_id,
requests_since,
requests_since.as_deref(),
next_token.as_deref(),
)?;
total += page.data.len();
Expand Down
10 changes: 5 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ enum Command {
/// App ID
app_id: i64,

/// Filter to consumers that have made requests since this date/time (ISO 8601)
/// Filter to consumers that have made requests since this datetime (ISO 8601 or relative duration, e.g. 24h, 7d)
#[arg(long)]
requests_since: Option<String>,

Expand Down Expand Up @@ -138,11 +138,11 @@ enum Command {
/// App ID
app_id: i64,

/// Since date/time (ISO 8601)
/// Since datetime (ISO 8601 or relative duration, e.g. 24h, 7d)
#[arg(long)]
since: String,

/// Until date/time (ISO 8601, defaults to now)
/// Until datetime (ISO 8601 or relative duration, e.g. 24h, 7d; defaults to now)
#[arg(long)]
until: Option<String>,

Expand Down Expand Up @@ -205,11 +205,11 @@ enum Command {
/// App ID
app_id: i64,

/// Since date/time (ISO 8601)
/// Since datetime (ISO 8601 or relative duration, e.g. 24h, 7d)
#[arg(long)]
since: String,

/// Until date/time (ISO 8601, defaults to now)
/// Until datetime (ISO 8601 or relative duration, e.g. 24h, 7d; defaults to now)
#[arg(long)]
until: Option<String>,

Expand Down
6 changes: 4 additions & 2 deletions src/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use duckdb::arrow::ipc::reader::StreamReader;
use duckdb::vtab::arrow::{ArrowVTab, arrow_recordbatch_to_query_params};

use crate::auth::{resolve_api_base_url, resolve_api_key};
use crate::utils::{api_post, input_err, open_db};
use crate::utils::{api_post, input_err, open_db, resolve_relative_datetime};

pub(crate) fn ensure_metrics_table(conn: &duckdb::Connection) -> Result<()> {
conn.execute_batch(
Expand Down Expand Up @@ -52,6 +52,8 @@ pub fn run(
let api_key = resolve_api_key(api_key)?;
let api_base_url = resolve_api_base_url(api_base_url);
let db = db.map(|p| open_db(p).map(|c| (p, c))).transpose()?;
let since = resolve_relative_datetime(since);
let until = until.map(resolve_relative_datetime);

let metrics_value: serde_json::Value = serde_json::from_str(metrics)
.map_err(|e| input_err(format!("invalid JSON for --metrics: {e}")))?;
Expand All @@ -61,7 +63,7 @@ pub fn run(
"since": since,
"metrics": metrics_value,
});
if let Some(until) = until {
if let Some(ref until) = until {
body["until"] = serde_json::json!(until);
}
if let Some(interval) = interval {
Expand Down
6 changes: 4 additions & 2 deletions src/request_logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use duckdb::arrow::ipc::reader::StreamReader;
use duckdb::vtab::arrow::{ArrowVTab, arrow_recordbatch_to_query_params};

use crate::auth::{resolve_api_base_url, resolve_api_key};
use crate::utils::{api_post, input_err, open_db};
use crate::utils::{api_post, input_err, open_db, resolve_relative_datetime};

pub(crate) fn ensure_request_logs_table(conn: &duckdb::Connection) -> Result<()> {
conn.execute_batch(
Expand Down Expand Up @@ -57,13 +57,15 @@ pub fn run(
let api_key = resolve_api_key(api_key)?;
let api_base_url = resolve_api_base_url(api_base_url);
let db = db.map(|p| open_db(p).map(|c| (p, c))).transpose()?;
let since = resolve_relative_datetime(since);
let until = until.map(resolve_relative_datetime);

let format = if db.is_some() { "arrow" } else { "ndjson" };
let mut body = serde_json::json!({
"format": format,
"since": since,
});
if let Some(until) = until {
if let Some(ref until) = until {
body["until"] = serde_json::json!(until);
}
if let Some(fields) = fields {
Expand Down
57 changes: 57 additions & 0 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,41 @@ pub fn open_db(path: &Path) -> Result<duckdb::Connection> {
.with_context(|| format!("failed to open database {}", path.display()))
}

/// If `s` is a compact relative duration (`<digits><m|h|d|w>`, e.g. `24h`, `7d`), returns the
/// corresponding UTC instant as RFC 3339 (never naive). Otherwise returns `s` unchanged.
pub fn resolve_relative_datetime(s: &str) -> String {
let b = s.as_bytes();
if b.len() < 2 {
return s.to_owned();
}
let unit = b[b.len() - 1];
if !matches!(unit, b'm' | b'h' | b'd' | b'w') {
Comment thread
itssimon marked this conversation as resolved.
return s.to_owned();
}
let prefix = &s[..s.len() - 1];
if prefix.is_empty() || !prefix.bytes().all(|c| c.is_ascii_digit()) {
return s.to_owned();
}
let Ok(n) = prefix.parse::<i64>() else {
return s.to_owned();
};
let secs = match unit {
b'm' => n.checked_mul(60),
b'h' => n.checked_mul(3600),
b'd' => n.checked_mul(86_400),
b'w' => n.checked_mul(604_800),
_ => None,
};
let Some(secs) = secs else {
return s.to_owned();
};
let duration = chrono::Duration::seconds(secs);
let Some(dt) = chrono::Utc::now().checked_sub_signed(duration) else {
return s.to_owned();
};
dt.to_rfc3339()
}

pub fn api_get(url: &str, api_key: &str, query: &[(&str, &str)]) -> Result<Response<Body>> {
let mut req = ureq::get(url)
.header("Api-Key", api_key)
Expand Down Expand Up @@ -128,6 +163,28 @@ mod tests {
assert!(resolved.to_string_lossy().contains(".apitally"));
}

#[test]
fn test_resolve_relative_datetime() {
assert_eq!(
resolve_relative_datetime("2025-01-01T00:00:00Z"),
"2025-01-01T00:00:00Z"
);

fn assert_approximately_now_minus(out: &str, expected_secs: i64) {
let t: chrono::DateTime<chrono::Utc> = out.parse().expect("parse rfc3339");
let ago = (chrono::Utc::now() - t).num_seconds();
assert!(
(ago - expected_secs).abs() <= 3,
"expected ~{expected_secs}s ago, got {ago}s ({out:?})"
);
}

assert_approximately_now_minus(&resolve_relative_datetime("30m"), 30 * 60);
assert_approximately_now_minus(&resolve_relative_datetime("2h"), 2 * 3600);
assert_approximately_now_minus(&resolve_relative_datetime("3d"), 259_200);
assert_approximately_now_minus(&resolve_relative_datetime("1w"), 604_800);
}

#[test]
fn test_check_response() {
assert!(check_response(&mut resp(200)).is_ok());
Expand Down
Loading