Skip to content

Commit fbfd2a2

Browse files
committed
Add bot traffic CLI commands
1 parent 7c0e2c0 commit fbfd2a2

5 files changed

Lines changed: 208 additions & 1 deletion

File tree

bin/cli.mjs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* npx @agent-analytics/cli create <name> — Create a project and get your snippet
99
* npx @agent-analytics/cli projects — List your projects
1010
* npx @agent-analytics/cli all-sites — Historical summary across all projects
11+
* npx @agent-analytics/cli bot-traffic <name> — Automated traffic filtered from tracking
1112
* npx @agent-analytics/cli stats <name> — Get stats for a project
1213
* npx @agent-analytics/cli events <name> — Get recent events
1314
* npx @agent-analytics/cli query <name> — Flexible analytics query
@@ -218,6 +219,59 @@ const cmdAllSites = withApi(async (api, opts = {}) => {
218219
log('');
219220
});
220221

222+
const cmdBotTraffic = withApi(async (api, target, opts = {}) => {
223+
const period = opts.period || '7d';
224+
const limit = parseInt(opts.limit || '10', 10);
225+
226+
if (!target) error('Usage: npx @agent-analytics/cli bot-traffic <project-name> [--period 7d] [--limit 10]\n npx @agent-analytics/cli bot-traffic --all [--period 7d] [--limit 10]');
227+
228+
const data = target === '--all'
229+
? await api.getAllSitesBotTraffic({ period, limit })
230+
: await api.getBotTraffic(target, { period, limit });
231+
232+
if (target === '--all') {
233+
heading(`All Sites Bot Traffic (${data.period?.label || period})`);
234+
log('');
235+
log(` ${BOLD}Automated requests:${RESET} ${data.summary?.automated_requests?.current || 0}`);
236+
log(` ${BOLD}Dropped events:${RESET} ${data.summary?.dropped_events?.current || 0}`);
237+
log(` ${BOLD}Projects:${RESET} ${data.summary?.active_projects || 0} active / ${data.summary?.total_projects || 0} total`);
238+
239+
if (data.projects?.length) {
240+
log('');
241+
heading('Top Projects:');
242+
for (const project of data.projects) {
243+
log(` ${BOLD}${project.name}${RESET} ${project.requests} requests ${DIM}${project.share_pct}% share${RESET}`);
244+
}
245+
}
246+
247+
log('');
248+
return;
249+
}
250+
251+
heading(`Bot Traffic: ${target}`);
252+
log('');
253+
log(` ${BOLD}Automated requests:${RESET} ${data.summary?.automated_requests?.current || 0}`);
254+
log(` ${BOLD}Dropped events:${RESET} ${data.summary?.dropped_events?.current || 0}`);
255+
256+
if (data.categories?.length) {
257+
log('');
258+
heading('Categories:');
259+
for (const category of data.categories) {
260+
log(` ${category.category} ${category.requests} requests ${DIM}${category.share_pct}% share${RESET}`);
261+
}
262+
}
263+
264+
if (data.actors?.length) {
265+
log('');
266+
heading('Top Actors:');
267+
for (const actor of data.actors) {
268+
log(` ${BOLD}${actor.actor}${RESET} ${actor.requests} requests ${DIM}${actor.category}${RESET}`);
269+
}
270+
}
271+
272+
log('');
273+
});
274+
221275
const cmdStats = withApi(async (api, project, days = 7) => {
222276
if (!project) error('Usage: npx @agent-analytics/cli stats <project-name> [--days N]');
223277

@@ -968,6 +1022,7 @@ ${BOLD}SETUP${RESET}
9681022
9691023
${BOLD}ANALYTICS${RESET}
9701024
${CYAN}all-sites${RESET} Historical summary across all projects
1025+
${CYAN}bot-traffic${RESET} <name> Filtered automated traffic by project or --all
9711026
${CYAN}stats${RESET} <name> Overview: events, users, daily trends
9721027
${CYAN}live${RESET} [name] Real-time terminal dashboard across all projects
9731028
${CYAN}insights${RESET} <name> Period-over-period comparison with trends
@@ -1057,6 +1112,14 @@ try {
10571112
limit: getArg('--limit') || '10',
10581113
});
10591114
break;
1115+
case 'bot-traffic': {
1116+
const botTrafficTarget = args[1] || (args.includes('--all') ? '--all' : null);
1117+
await cmdBotTraffic(botTrafficTarget, {
1118+
period: getArg('--period') || '7d',
1119+
limit: getArg('--limit') || '10',
1120+
});
1121+
break;
1122+
}
10601123
case 'stats':
10611124
await cmdStats(args[1], parseInt(getArg('--days') || '7', 10));
10621125
break;

lib/api.mjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export class AgentAnalyticsAPI {
3636
const data = await res.json();
3737

3838
if (!res.ok) {
39-
throw new Error(data.error || `HTTP ${res.status}`);
39+
throw new Error(data.message || data.error || `HTTP ${res.status}`);
4040
}
4141

4242
if (returnHeaders) {
@@ -61,6 +61,14 @@ export class AgentAnalyticsAPI {
6161
return this.request('GET', `/account/all-sites?${this._qs({ period, limit })}`);
6262
}
6363

64+
async getBotTraffic(project, { period = '7d', limit = 10 } = {}) {
65+
return this.request('GET', `/bot-traffic?${this._qs({ project, period, limit })}`);
66+
}
67+
68+
async getAllSitesBotTraffic({ period = '7d', limit = 10 } = {}) {
69+
return this.request('GET', `/account/bot-traffic?${this._qs({ period, limit })}`);
70+
}
71+
6472
// Projects
6573
async createProject(name, allowedOrigins = '*') {
6674
return this.request('POST', '/projects', {

test/api.test.mjs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,20 @@ describe('AgentAnalyticsAPI', () => {
7474
);
7575
});
7676

77+
it('prefers human-readable message over error code', async () => {
78+
globalThis.fetch = async () => ({
79+
ok: false,
80+
status: 403,
81+
json: async () => ({ error: 'PRO_REQUIRED', message: 'experiments require pro tier' }),
82+
});
83+
84+
const api = new AgentAnalyticsAPI('aak_free', 'https://test.example.com');
85+
await assert.rejects(
86+
() => api.request('POST', '/experiments'),
87+
{ message: 'experiments require pro tier' }
88+
);
89+
});
90+
7791
it('throws with HTTP status when no error message', async () => {
7892
globalThis.fetch = async () => ({
7993
ok: false,
@@ -167,6 +181,18 @@ describe('AgentAnalyticsAPI', () => {
167181
assert.equal(lastUrl, 'https://api.test/account/all-sites?period=30d&limit=5');
168182
assert.equal(lastMethod, 'GET');
169183
});
184+
185+
it('getBotTraffic → GET /bot-traffic', async () => {
186+
await api.getBotTraffic('my-site', { period: '14d', limit: 3 });
187+
assert.equal(lastUrl, 'https://api.test/bot-traffic?project=my-site&period=14d&limit=3');
188+
assert.equal(lastMethod, 'GET');
189+
});
190+
191+
it('getAllSitesBotTraffic → GET /account/bot-traffic', async () => {
192+
await api.getAllSitesBotTraffic({ period: '30d', limit: 8 });
193+
assert.equal(lastUrl, 'https://api.test/account/bot-traffic?period=30d&limit=8');
194+
assert.equal(lastMethod, 'GET');
195+
});
170196
});
171197

172198
});

test/bot-traffic-render.test.mjs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { execFile } from 'node:child_process';
4+
import { createServer } from 'node:http';
5+
import { fileURLToPath } from 'node:url';
6+
import { dirname, join } from 'node:path';
7+
8+
const __dirname = dirname(fileURLToPath(import.meta.url));
9+
const CLI = join(__dirname, '..', 'bin', 'cli.mjs');
10+
11+
function startMockServer(handler) {
12+
return new Promise((resolve) => {
13+
const server = createServer((req, res) => {
14+
res.writeHead(200, { 'Content-Type': 'application/json' });
15+
res.end(JSON.stringify(handler(req)));
16+
});
17+
server.listen(0, '127.0.0.1', () => {
18+
const port = server.address().port;
19+
resolve({ server, url: `http://127.0.0.1:${port}` });
20+
});
21+
});
22+
}
23+
24+
function runCli(args, env) {
25+
return new Promise((resolve) => {
26+
execFile('node', [CLI, ...args], { timeout: 5000, env: { ...process.env, ...env } }, (err, stdout, stderr) => {
27+
resolve({ code: err ? err.code : 0, stdout, stderr });
28+
});
29+
});
30+
}
31+
32+
describe('bot-traffic command rendering', () => {
33+
it('renders project bot traffic summaries', async () => {
34+
const { server, url } = await startMockServer(() => ({
35+
scope: 'project',
36+
project: 'test-site',
37+
period: { label: '7d', from: '2026-03-07', to: '2026-03-13' },
38+
summary: {
39+
automated_requests: { current: 5, previous: 2, change: 3, change_pct: 150 },
40+
dropped_events: { current: 8, previous: 3, change: 5, change_pct: 167 },
41+
last_seen_at: 1773370000000,
42+
},
43+
categories: [
44+
{ category: 'ai_agent', requests: 3, dropped_events: 6, share_pct: 60 },
45+
{ category: 'search_crawler', requests: 2, dropped_events: 2, share_pct: 40 },
46+
],
47+
actors: [
48+
{ actor: 'ChatGPT-User', category: 'ai_agent', requests: 3, dropped_events: 6, last_seen_at: 1773370000000 },
49+
],
50+
time_series: [
51+
{ date: '2026-03-13', requests: 5, dropped_events: 8 },
52+
],
53+
}));
54+
55+
try {
56+
const { code, stdout } = await runCli(['bot-traffic', 'test-site'], {
57+
AGENT_ANALYTICS_API_KEY: 'aak_test',
58+
AGENT_ANALYTICS_URL: url,
59+
});
60+
61+
assert.equal(code, 0);
62+
assert.ok(stdout.includes('Bot Traffic: test-site'));
63+
assert.ok(stdout.includes('Automated requests'));
64+
assert.ok(stdout.includes('Dropped events'));
65+
assert.ok(stdout.includes('ChatGPT-User'));
66+
assert.ok(stdout.includes('ai_agent'));
67+
} finally {
68+
server.close();
69+
}
70+
});
71+
72+
it('renders account bot traffic summaries with --all', async () => {
73+
const { server, url } = await startMockServer(() => ({
74+
scope: 'account',
75+
period: { label: '7d', from: '2026-03-07', to: '2026-03-13' },
76+
summary: {
77+
automated_requests: { current: 7, previous: 4, change: 3, change_pct: 75 },
78+
dropped_events: { current: 10, previous: 5, change: 5, change_pct: 100 },
79+
active_projects: 2,
80+
total_projects: 3,
81+
last_seen_at: 1773370000000,
82+
},
83+
categories: [
84+
{ category: 'ai_agent', requests: 4, dropped_events: 7, share_pct: 57.1 },
85+
],
86+
projects: [
87+
{ name: 'test-site', requests: 4, dropped_events: 7, share_pct: 57.1, last_seen_at: '2026-03-13' },
88+
],
89+
remaining_projects: 0,
90+
time_series: [
91+
{ date: '2026-03-13', requests: 7, dropped_events: 10 },
92+
],
93+
}));
94+
95+
try {
96+
const { code, stdout } = await runCli(['bot-traffic', '--all'], {
97+
AGENT_ANALYTICS_API_KEY: 'aak_test',
98+
AGENT_ANALYTICS_URL: url,
99+
});
100+
101+
assert.equal(code, 0);
102+
assert.ok(stdout.includes('All Sites Bot Traffic'));
103+
assert.ok(stdout.includes('2 active / 3 total'));
104+
assert.ok(stdout.includes('test-site'));
105+
} finally {
106+
server.close();
107+
}
108+
});
109+
});

test/cli.test.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('CLI', () => {
2929
assert.ok(stdout.includes('login'));
3030
assert.ok(stdout.includes('stats'));
3131
assert.ok(stdout.includes('all-sites'));
32+
assert.ok(stdout.includes('bot-traffic'));
3233
});
3334

3435
it('shows help with help command', async () => {

0 commit comments

Comments
 (0)