Skip to content

Commit 6f28de9

Browse files
authored
Add endpoints command (#14)
* Add endpoints command * Fix * Update skill * Fix
1 parent cdcc2a4 commit 6f28de9

9 files changed

Lines changed: 312 additions & 27 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ src/
1313
whoami.rs Whoami command (auth check, team info)
1414
apps.rs Apps command (fetch, DB write)
1515
consumers.rs Consumers command (paginated fetch, DB write)
16+
endpoints.rs Endpoints command (fetch, DB write)
1617
request_logs.rs Request logs command (Arrow IPC or NDJSON streaming)
1718
request_details.rs Request details command (single request fetch, DB write)
1819
sql.rs SQL command (query DuckDB, output NDJSON)
@@ -44,6 +45,7 @@ skills/
4445
| `whoami` | `GET /v1/team` |
4546
| `apps` | `GET /v1/apps` |
4647
| `consumers` | `GET /v1/apps/{app_id}/consumers` |
48+
| `endpoints` | `GET /v1/apps/{app_id}/endpoints` |
4749
| `request-logs` | `POST /v1/apps/{app_id}/request-logs/stream` |
4850
| `request-details` | `GET /v1/apps/{app_id}/request-logs/{request_uuid}` |
4951
| `sql` | Local DuckDB |

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ You can also set the API key via the `APITALLY_API_KEY` environment variable or
7878
| `whoami` | Check authentication and show team info |
7979
| `apps` | List all apps in your team |
8080
| `consumers` | List consumers for an app |
81+
| `endpoints` | List endpoints for an app |
8182
| `request-logs` | Fetch request log data for an app |
8283
| `request-details` | Fetch full details for a specific request |
8384
| `sql` | Run SQL queries against a local DuckDB database |

skills/apitally-cli/SKILL.md

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ All commands are run via `npx @apitally/cli <command>`. For full details, see [r
3737
- `whoami` -- check auth, show team
3838
- `apps [--db [<path>]]` -- list apps (get app IDs)
3939
- `consumers <app-id> [--requests-since <dt>] [--db [<path>]]` -- list consumers for an app (get consumer IDs)
40+
- `endpoints <app-id> [--method <methods>] [--path <pattern>] [--db [<path>]]` -- list endpoints for an app
4041
- `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)
4142
- `request-details <app-id> <request-uuid> [--db [<path>]]` -- fetch full details for a single request (including headers, payloads, exception info, application logs, and spans)
4243
- `sql "<query>" [--db <path>]` -- run SQL against local DuckDB
@@ -50,18 +51,21 @@ All commands are run via `npx @apitally/cli <command>`. For full details, see [r
5051

5152
3. **Determine the time range** — check if the user specified a time range (e.g. "last 24 hours", "since Monday", a specific date). If not, default to the last 7 days. Use this time range consistently for `--requests-since` / `--since` / `--until` flags and SQL `WHERE` conditions throughout the investigation.
5253

53-
4. **Determine if consumers are involved** — decide which scenario applies:
54-
- **(a) Specific consumer(s)**: the user is asking about specific consumers (e.g. by email, name, or group). Fetch consumers first, then query to find the matching `consumer_id`, then use it as a filter when fetching request logs.
55-
- **(b) Consumer context needed**: the investigation involves consumers but not specific ones known upfront (e.g. "which consumers cause the most errors"). Fetch consumers into DuckDB for later JOINs with request logs.
56-
- **(c) No consumer involvement**: skip fetching consumers.
54+
4. **Fetch endpoints if needed** — skip this step unless you need to discover available endpoints to filter request logs. Fetch endpoints using the `endpoints` command:
5755

58-
5. **Fetch consumers** into DuckDB using the `consumers` command (only if scenario (a) or (b) applies):
56+
```
57+
npx @apitally/cli endpoints <app-id> [--method <methods>] [--path <pattern>]
58+
```
59+
60+
Use `--method` and/or `--path` to filter (e.g. `--path '*users*'`). Read the NDJSON output to identify relevant endpoints, then use their method/path to filter request logs in step 6.
61+
62+
5. **Fetch consumers if needed** — skip this step if the investigation doesn't involve consumers. Otherwise, fetch consumers into DuckDB using the `consumers` command:
5963

6064
```
6165
npx @apitally/cli consumers <app-id> [--requests-since "<since>"] --db
6266
```
6367

64-
For scenario (a), query to find the consumer IDs:
68+
If the user is asking about specific consumers (e.g. by email, name, or group), query to find their `consumer_id` and use it as a filter when fetching request logs in step 6:
6569

6670
```
6771
npx @apitally/cli sql "SELECT consumer_id, identifier, name, \"group\" FROM consumers WHERE app_id = <app-id> AND identifier ILIKE '%@example.com'"
@@ -76,7 +80,9 @@ All commands are run via `npx @apitally/cli <command>`. For full details, see [r
7680
--db
7781
```
7882

79-
For scenario (a), add a consumer filter: `{"field":"consumer_id","op":"in","value":[1,2,3]}`
83+
If filtering by endpoint, add method/path filters: `[{"field":"method","op":"eq","value":"GET"},{"field":"path","op":"eq","value":"/v1/users/{user_id}"}]`
84+
85+
If filtering by consumers, add a consumer filter: `[{"field":"consumer_id","op":"in","value":[1,2,3]}]`
8086

8187
Narrow down fields and use filters as much as possible to avoid fetching unnecessarily large volumes of data. Refetching data later (e.g. with more fields) replaces existing records in DuckDB and does not create duplicates.
8288

@@ -114,21 +120,6 @@ WHERE r.app_id = <app-id>
114120
ORDER BY r.timestamp DESC
115121
```
116122

117-
### Top consumers by error count
118-
119-
```sql
120-
SELECT c.identifier, c.name,
121-
COUNT(*) as total_requests,
122-
SUM(CASE WHEN r.status_code >= 400 THEN 1 ELSE 0 END) as errors
123-
FROM request_logs r
124-
JOIN consumers c ON r.app_id = c.app_id AND r.consumer_id = c.consumer_id
125-
WHERE r.app_id = <app-id>
126-
AND r.timestamp >= '<since>'
127-
GROUP BY c.identifier, c.name
128-
ORDER BY errors DESC
129-
LIMIT 20
130-
```
131-
132123
### Exception investigation
133124

134125
Fetch with exception fields first:

skills/apitally-cli/references/commands.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,25 @@ Example NDJSON output (without `--db`):
6262
{"id":2,"identifier":"alice@example.com","name":"Alice","group":null,"created_at":"2026-01-02T00:00:00Z","last_request_at":"2026-01-02T02:00:00Z"}
6363
```
6464

65+
## `endpoints`
66+
67+
```
68+
npx @apitally/cli endpoints <app-id> [--method <methods>] [--path <pattern>] [--db [<path>]]
69+
```
70+
71+
List API endpoints for an app, ordered by path and method. Use this to see which endpoints exist for an app. Outputs NDJSON to stdout by default.
72+
73+
- `--method`: Filter to HTTP method(s), comma-separated (e.g. `GET,POST`)
74+
- `--path`: Filter to path pattern, supports wildcards (e.g. `/v1/*`)
75+
- `--db`: Write to `endpoints` table in DuckDB instead of outputting NDJSON to stdout
76+
77+
Example NDJSON output (without `--db`):
78+
79+
```json
80+
{"id":1,"method":"POST","path":"/v1/users"}
81+
{"id":2,"method":"GET","path":"/v1/users/{user_id}"}
82+
```
83+
6584
## `request-logs`
6685

6786
```
@@ -187,7 +206,7 @@ Run a SQL query against a local DuckDB database. The query can be passed as an a
187206

188207
- `--db`: Path to DuckDB database
189208

190-
Available tables: `apps`, `app_envs`, `consumers`, `request_logs`, `application_logs`, `spans`. See [duckdb_tables.md](duckdb_tables.md) for schemas.
209+
Available tables: `apps`, `app_envs`, `consumers`, `endpoints`, `request_logs`, `application_logs`, `spans`. See [duckdb_tables.md](duckdb_tables.md) for schemas.
191210

192211
**Important:** The database may contain data from previous sessions. Always filter queries by `app_id`, `timestamp`, and other relevant fields to avoid including unrelated data.
193212

skills/apitally-cli/references/duckdb_tables.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# DuckDB Table Schemas
22

3-
Tables are created automatically when using the `--db` flag with `apps`, `consumers`, `request-logs`, or `request-details` commands. DuckDB uses a [PostgreSQL-compatible SQL dialect](https://duckdb.org/docs/stable/sql/dialect/overview).
3+
Tables are created automatically when using the `--db` flag with `apps`, `consumers`, `endpoints`, `request-logs`, or `request-details` commands. DuckDB uses a [PostgreSQL-compatible SQL dialect](https://duckdb.org/docs/stable/sql/dialect/overview).
44

55
## apps
66

@@ -44,6 +44,18 @@ CREATE TABLE consumers (
4444

4545
The `identifier` is the consumer string set in the application (e.g. email, username, API key name). The `"group"` column name is quoted because it is a reserved word in SQL.
4646

47+
## endpoints
48+
49+
```sql
50+
CREATE TABLE endpoints (
51+
app_id INTEGER NOT NULL,
52+
endpoint_id INTEGER NOT NULL,
53+
method TEXT NOT NULL,
54+
path TEXT NOT NULL,
55+
UNIQUE (app_id, endpoint_id)
56+
);
57+
```
58+
4759
## request_logs
4860

4961
```sql
@@ -117,6 +129,7 @@ Populated by the `request-details` command when using `--db`.
117129
## Relationships
118130

119131
- `request_logs.consumer_id` references `consumers.consumer_id` (join on both `app_id` and `consumer_id`)
132+
- `endpoints.app_id` references `apps.app_id`
120133
- `request_logs.app_id` references `apps.app_id`
121134
- `app_envs.app_id` references `apps.app_id`
122135
- `request_logs.env` matches `app_envs.name` (string, not a foreign key to `app_env_id`)

src/apps.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ pub fn run(
106106
ensure_apps_tables(&conn)?;
107107
write_apps_to_db(&conn, &apps)?;
108108
eprintln!(
109-
"{} apps written to table 'apps' in {}...\nDone.",
109+
"{} apps written to table 'apps' in {}.\nDone.",
110110
apps.len(),
111111
db_path.display(),
112112
);

src/endpoints.rs

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
use std::io::Write;
2+
use std::path::Path;
3+
4+
use anyhow::Result;
5+
use serde::{Deserialize, Serialize};
6+
7+
use crate::auth::{resolve_api_base_url, resolve_api_key};
8+
use crate::utils::{api_get, open_db};
9+
10+
#[derive(Deserialize)]
11+
struct EndpointsResponse {
12+
data: Vec<EndpointItem>,
13+
}
14+
15+
#[derive(Deserialize, Serialize)]
16+
struct EndpointItem {
17+
id: i64,
18+
method: String,
19+
path: String,
20+
}
21+
22+
fn fetch_endpoints(
23+
api_key: &str,
24+
api_base_url: &str,
25+
app_id: i64,
26+
method: Option<&str>,
27+
path: Option<&str>,
28+
) -> Result<Vec<EndpointItem>> {
29+
let url = format!("{api_base_url}/v1/apps/{app_id}/endpoints");
30+
let mut query: Vec<(&str, &str)> = Vec::new();
31+
if let Some(m) = method {
32+
query.push(("method", m));
33+
}
34+
if let Some(p) = path {
35+
query.push(("path", p));
36+
}
37+
let mut response = api_get(&url, api_key, &query)?;
38+
let endpoints: EndpointsResponse = response.body_mut().read_json()?;
39+
Ok(endpoints.data)
40+
}
41+
42+
pub(crate) fn ensure_endpoints_table(conn: &duckdb::Connection) -> Result<()> {
43+
conn.execute_batch(
44+
"CREATE TABLE IF NOT EXISTS endpoints (
45+
app_id INTEGER NOT NULL,
46+
endpoint_id INTEGER NOT NULL,
47+
method TEXT NOT NULL,
48+
path TEXT NOT NULL,
49+
UNIQUE (app_id, endpoint_id)
50+
)",
51+
)?;
52+
Ok(())
53+
}
54+
55+
fn write_endpoints_to_db(
56+
conn: &duckdb::Connection,
57+
app_id: i64,
58+
endpoints: &[EndpointItem],
59+
) -> Result<()> {
60+
let mut stmt = conn.prepare(
61+
"INSERT OR REPLACE INTO endpoints (
62+
app_id, endpoint_id, method, path
63+
) VALUES (?, ?, ?, ?)",
64+
)?;
65+
for endpoint in endpoints {
66+
stmt.execute(duckdb::params![
67+
app_id,
68+
endpoint.id,
69+
endpoint.method,
70+
endpoint.path,
71+
])?;
72+
}
73+
Ok(())
74+
}
75+
76+
pub fn run(
77+
app_id: i64,
78+
method: Option<&str>,
79+
path: Option<&str>,
80+
db: Option<&Path>,
81+
api_key: Option<&str>,
82+
api_base_url: Option<&str>,
83+
mut writer: impl Write,
84+
) -> Result<()> {
85+
let api_key = resolve_api_key(api_key)?;
86+
let api_base_url = resolve_api_base_url(api_base_url);
87+
let endpoints = fetch_endpoints(&api_key, &api_base_url, app_id, method, path)?;
88+
89+
if let Some(db_path) = db {
90+
let conn = open_db(db_path)?;
91+
ensure_endpoints_table(&conn)?;
92+
write_endpoints_to_db(&conn, app_id, &endpoints)?;
93+
eprintln!(
94+
"{} endpoints written to table 'endpoints' in {}.\nDone.",
95+
endpoints.len(),
96+
db_path.display(),
97+
);
98+
} else {
99+
for endpoint in &endpoints {
100+
serde_json::to_writer(&mut writer, endpoint)?;
101+
writeln!(writer)?;
102+
}
103+
}
104+
105+
Ok(())
106+
}
107+
108+
#[cfg(test)]
109+
mod tests {
110+
use super::*;
111+
use crate::utils::open_db;
112+
use crate::utils::test_utils::{parse_ndjson, temp_db};
113+
114+
fn sample_endpoints_json() -> &'static str {
115+
r#"{
116+
"data": [
117+
{
118+
"id": 1,
119+
"method": "POST",
120+
"path": "/v1/users"
121+
},
122+
{
123+
"id": 2,
124+
"method": "GET",
125+
"path": "/v1/users/{user_id}"
126+
}
127+
]
128+
}"#
129+
}
130+
131+
fn mock_endpoints_endpoint(server: &mut mockito::Server, app_id: i64) -> mockito::Mock {
132+
let path = format!("/v1/apps/{app_id}/endpoints");
133+
server
134+
.mock("GET", path.as_str())
135+
.with_status(200)
136+
.with_header("content-type", "application/json")
137+
.with_body(sample_endpoints_json())
138+
.create()
139+
}
140+
141+
#[test]
142+
fn test_run_ndjson() {
143+
let mut server = mockito::Server::new();
144+
let mock = mock_endpoints_endpoint(&mut server, 1);
145+
146+
let mut buf = Vec::new();
147+
run(
148+
1,
149+
None,
150+
None,
151+
None,
152+
Some("test-key"),
153+
Some(&server.url()),
154+
&mut buf,
155+
)
156+
.unwrap();
157+
mock.assert();
158+
159+
let rows = parse_ndjson(&buf);
160+
assert_eq!(rows.len(), 2);
161+
assert_eq!(rows[0]["method"], "POST");
162+
assert_eq!(rows[0]["path"], "/v1/users");
163+
assert_eq!(rows[1]["method"], "GET");
164+
assert_eq!(rows[1]["path"], "/v1/users/{user_id}");
165+
}
166+
167+
#[test]
168+
fn test_run_with_db() {
169+
let mut server = mockito::Server::new();
170+
let mock = mock_endpoints_endpoint(&mut server, 1);
171+
let (_dir, db_path) = temp_db();
172+
173+
run(
174+
1,
175+
None,
176+
None,
177+
Some(&db_path),
178+
Some("test-key"),
179+
Some(&server.url()),
180+
Vec::new(),
181+
)
182+
.unwrap();
183+
mock.assert();
184+
185+
let conn = open_db(&db_path).unwrap();
186+
187+
let count: i64 = conn
188+
.query_row(
189+
"SELECT count(*) FROM endpoints WHERE app_id = 1",
190+
[],
191+
|row| row.get(0),
192+
)
193+
.unwrap();
194+
assert_eq!(count, 2);
195+
196+
let method: String = conn
197+
.query_row(
198+
"SELECT method FROM endpoints WHERE endpoint_id = 1",
199+
[],
200+
|row| row.get(0),
201+
)
202+
.unwrap();
203+
assert_eq!(method, "POST");
204+
}
205+
}

0 commit comments

Comments
 (0)