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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,11 +316,12 @@ If you're already running this library, **you can skip the log drain** — your
void trackVisit(req, {
analytics,
captureCountry: true, // emits country_code from x-vercel-ip-country / cf-ipcountry / x-country-code
captureGeo: true, // emits region, city, latitude, longitude, timezone from x-vercel-ip-* (URL-decoded)
captureIp: true // emits raw client_ip (first hop of x-forwarded-for)
})
```

Both default to **off** so the library stays PII-free out of the box. Enable them only on the deployments you intend to export.
All three default to **off** so the library stays PII-free out of the box. Enable them only on the deployments you intend to export. `captureGeo` is more identifying than `captureCountry` (city resolution vs. country) — opt in deliberately.

Then export from PostHog with a SQL insight:

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.8.0",
"version": "0.9.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
27 changes: 27 additions & 0 deletions src/track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export async function trackVisit(
req.headers.get('x-country-code') ||
null
: null
const geo = opts.captureGeo ? extractGeo(req) : null
const classification = classifyRequest(req)

const event = {
Expand All @@ -60,6 +61,7 @@ export async function trackVisit(
path: pathname,
method: req.method,
...(opts.captureCountry ? { country_code: country } : {}),
...(geo ?? {}),
...(opts.captureIp ? { client_ip: ip || null } : {}),
user_agent: userAgent,
is_ai_bot: classification.isAiBot,
Expand All @@ -80,3 +82,28 @@ export async function trackVisit(
// Intentional swallow — analytics failures must not affect the response.
}
}

// Vercel edge URL-encodes city/region (e.g. `San%20Francisco`); decode so
// downstream consumers don't have to. Numeric fields (lat/lng) and timezone
// pass through untouched. Headers without a value are dropped rather than
// emitted as empty strings.
function extractGeo(req: Request): Record<string, string> {
const decode = (v: string | null) => {
if (!v) return ''
try {
return decodeURIComponent(v)
} catch {
return v
}
}
const fields: Array<[string, string]> = [
['region', decode(req.headers.get('x-vercel-ip-country-region'))],
['city', decode(req.headers.get('x-vercel-ip-city'))],
['latitude', req.headers.get('x-vercel-ip-latitude') ?? ''],
['longitude', req.headers.get('x-vercel-ip-longitude') ?? ''],
['timezone', req.headers.get('x-vercel-ip-timezone') ?? '']
]
const out: Record<string, string> = {}
for (const [k, v] of fields) if (v) out[k] = v
return out
}
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,14 @@ export interface TrackVisitOptions {
* Enable for log-style exports (e.g. Peec.ai's crawl-insights CSV).
*/
captureCountry?: boolean
/**
* When `true`, emit `region`, `city`, `latitude`, `longitude`, and
* `timezone` derived from Vercel's `x-vercel-ip-*` edge headers. Values
* are URL-decoded (Vercel encodes city/region, e.g. `San%20Francisco`).
* Missing headers are omitted rather than emitted as empty strings.
*
* Off by default — geo at city resolution is more identifying than country
* alone. Pair with `captureCountry` for a full Peec.ai-style export.
*/
captureGeo?: boolean
}
52 changes: 52 additions & 0 deletions test/track.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,58 @@ describe('trackVisit', () => {
expect(event.properties.country_code).toBe('NL')
})

it('emits decoded geo fields when captureGeo is true', async () => {
const spy = vi.fn()
await trackVisit(
makeRequest('https://example.com/page', {
'user-agent': 'ClaudeBot',
'x-vercel-ip-country-region': 'CA',
'x-vercel-ip-city': 'San%20Francisco',
'x-vercel-ip-latitude': '37.7749',
'x-vercel-ip-longitude': '-122.4194',
'x-vercel-ip-timezone': 'America/Los_Angeles'
}),
{ analytics: customAnalytics(spy), captureGeo: true }
)
const event = spy.mock.calls[0]![0] as CaptureEvent
expect(event.properties.region).toBe('CA')
expect(event.properties.city).toBe('San Francisco')
expect(event.properties.latitude).toBe('37.7749')
expect(event.properties.longitude).toBe('-122.4194')
expect(event.properties.timezone).toBe('America/Los_Angeles')
})

it('omits geo fields whose headers are missing', async () => {
const spy = vi.fn()
await trackVisit(
makeRequest('https://example.com/page', {
'user-agent': 'ClaudeBot',
'x-vercel-ip-city': 'Amsterdam'
}),
{ analytics: customAnalytics(spy), captureGeo: true }
)
const event = spy.mock.calls[0]![0] as CaptureEvent
expect(event.properties.city).toBe('Amsterdam')
expect(event.properties).not.toHaveProperty('region')
expect(event.properties).not.toHaveProperty('latitude')
expect(event.properties).not.toHaveProperty('timezone')
})

it('omits geo fields when captureGeo is not set', async () => {
const spy = vi.fn()
await trackVisit(
makeRequest('https://example.com/page', {
'user-agent': 'ClaudeBot',
'x-vercel-ip-city': 'Amsterdam',
'x-vercel-ip-timezone': 'Europe/Amsterdam'
}),
{ analytics: customAnalytics(spy) }
)
const event = spy.mock.calls[0]![0] as CaptureEvent
expect(event.properties).not.toHaveProperty('city')
expect(event.properties).not.toHaveProperty('timezone')
})

it('falls back to cf-ipcountry for country_code', async () => {
const spy = vi.fn()
await trackVisit(
Expand Down
Loading