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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ One line of middleware. Fire-and-forget. Zero impact on your response latency. E
"$process_person_profile": false, // PostHog: don't create a person
"$current_url": "https://example.com/docs/intro",
"path": "/docs/intro",
"method": "GET",
"country_code": "NL", // x-vercel-ip-country / cf-ipcountry / x-country-code
"user_agent": "ClaudeBot/1.0 (+https://claude.ai/bot)",
"is_ai_bot": true, // strict: matches a branded AI crawler
"bot_name": "Claude", // 'Claude' | 'ChatGPT' | ... | 'curl' | 'axios' | 'Electron' | 'Browser' | 'Other'
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@apideck/agent-analytics",
"version": "0.7.0",
"version": "0.8.0",
"description": "Track AI agent and bot traffic to your Next.js / Vercel app — PostHog, webhooks, or any custom analytics backend. Detects Claude, ChatGPT, Perplexity, Google-Extended, and more.",
"keywords": [
"ai",
Expand Down
8 changes: 8 additions & 0 deletions src/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ export async function trackVisit(
const forwardedFor = req.headers.get('x-forwarded-for') || ''
const ip = forwardedFor.split(',')[0]?.trim() ?? ''
const referer = req.headers.get('referer')
const country =
req.headers.get('x-vercel-ip-country') ||
req.headers.get('cf-ipcountry') ||
req.headers.get('x-country-code') ||
null
const classification = classifyRequest(req)

const event = {
Expand All @@ -52,6 +57,9 @@ export async function trackVisit(
$process_person_profile: false,
$current_url: origin ? `${origin}${pathname}` : pathname,
path: pathname,
method: req.method,
country_code: country,
...(opts.captureIp ? { client_ip: ip || null } : {}),
user_agent: userAgent,
is_ai_bot: classification.isAiBot,
bot_name: classification.label,
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,11 @@ export interface TrackVisitOptions {
* origin.
*/
origin?: string
/**
* When `true`, emit the raw `client_ip` (first hop of `x-forwarded-for`) on
* the event. Off by default — the IP is always hashed into `distinctId`,
* but the raw value is only useful for log-style exports (e.g. Peec.ai's
* crawl-insights CSV) and carries privacy implications.
*/
captureIp?: boolean
}
45 changes: 45 additions & 0 deletions test/track.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,51 @@ describe('trackVisit', () => {
expect(a.distinctId).not.toBe(b.distinctId)
})

it('captures method, country_code, and omits client_ip by default', async () => {
const spy = vi.fn()
await trackVisit(
new Request('https://example.com/page', {
method: 'POST',
headers: {
'user-agent': 'ClaudeBot',
'x-forwarded-for': '203.0.113.1',
'x-vercel-ip-country': 'NL'
}
}),
{ analytics: customAnalytics(spy) }
)
const event = spy.mock.calls[0]![0] as CaptureEvent
expect(event.properties.method).toBe('POST')
expect(event.properties.country_code).toBe('NL')
expect(event.properties).not.toHaveProperty('client_ip')
})

it('falls back to cf-ipcountry for country_code', async () => {
const spy = vi.fn()
await trackVisit(
makeRequest('https://example.com/page', {
'user-agent': 'ClaudeBot',
'cf-ipcountry': 'US'
}),
{ analytics: customAnalytics(spy) }
)
const event = spy.mock.calls[0]![0] as CaptureEvent
expect(event.properties.country_code).toBe('US')
})

it('emits client_ip when captureIp is true', async () => {
const spy = vi.fn()
await trackVisit(
makeRequest('https://example.com/page', {
'user-agent': 'ClaudeBot',
'x-forwarded-for': '203.0.113.1, 10.0.0.1'
}),
{ analytics: customAnalytics(spy), captureIp: true }
)
const event = spy.mock.calls[0]![0] as CaptureEvent
expect(event.properties.client_ip).toBe('203.0.113.1')
})

it('uses the first x-forwarded-for value when multiple are present', async () => {
const spy = vi.fn()
await trackVisit(
Expand Down
Loading