From 749d275080406cc2339be832a8e812fa80eb032f Mon Sep 17 00:00:00 2001 From: GJ Date: Sun, 26 Apr 2026 22:49:35 +0200 Subject: [PATCH] feat: add captureGeo opt-in for region/city/lat/lng/timezone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `captureGeo` flag that, when enabled, attaches `region`, `city`, `latitude`, `longitude`, and `timezone` derived from Vercel's `x-vercel-ip-*` edge headers. City and region are URL-decoded (Vercel encodes them, e.g. `San%20Francisco`). Missing headers are omitted rather than emitted as empty strings. Off by default — city-resolution geo is more identifying than the country-only `captureCountry` flag, so opt in deliberately. This pulls a `geoProperties` helper out of apideck-io/developer-docs and into the library so other Vercel-hosted consumers don't have to reinvent it. Bumps to 0.9.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 3 ++- package.json | 2 +- src/track.ts | 27 ++++++++++++++++++++++++ src/types.ts | 10 +++++++++ test/track.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d362db4..74e3c8c 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/package.json b/package.json index a59cf68..d158cf8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/track.ts b/src/track.ts index 4fc6687..1be6dbc 100644 --- a/src/track.ts +++ b/src/track.ts @@ -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 = { @@ -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, @@ -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 { + 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 = {} + for (const [k, v] of fields) if (v) out[k] = v + return out +} diff --git a/src/types.ts b/src/types.ts index 69f3799..235d14c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 } diff --git a/test/track.test.ts b/test/track.test.ts index 653651b..d0fa7bd 100644 --- a/test/track.test.ts +++ b/test/track.test.ts @@ -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(