diff --git a/README.md b/README.md index 81fa9af..bdbf7ba 100644 --- a/README.md +++ b/README.md @@ -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' diff --git a/package.json b/package.json index 6d0ffaf..a59cf68 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/track.ts b/src/track.ts index 342d311..a84e890 100644 --- a/src/track.ts +++ b/src/track.ts @@ -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 = { @@ -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, diff --git a/src/types.ts b/src/types.ts index f3d3f86..7899b0a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 } diff --git a/test/track.test.ts b/test/track.test.ts index 6b43086..fee836e 100644 --- a/test/track.test.ts +++ b/test/track.test.ts @@ -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(