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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: [18, 20, 22]
# Node 18 dropped in 0.12: no globalThis.crypto by default (needs 19+),
# and it reached end of life in April 2025.
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,62 @@ Now you can build:

---

## Upgrading to 0.12

Four breaking changes, all deliberate. Each one existed because the previous
behaviour was wrong in a way that failed quietly.

**`distinctId` is now keyed.** The old identifier was an unsalted 32-bit djb2
over `ip:userAgent`. Since the user agent ships in plaintext on the same event,
only the IP had to be searched β€” a laptop recovered a residential address in
75 seconds. Set `idSecret` (or `AGENT_ANALYTICS_ID_SECRET`) to a stable secret;
without one, a random per-instance secret is used, which stays private but
means ids no longer correlate across instances. Existing ids will not match the
new ones either way.

```diff
- void trackVisit(req, { analytics })
+ void trackVisit(req, { analytics, idSecret: process.env.AGENT_ANALYTICS_ID_SECRET })
```

**`verifyIdentity: true` is replaced by an injected verifier.** The published
IP range tables are the largest thing in the package, and importing them from
the root entry shipped them to every consumer whether or not they verified
anything. They now live behind `@apideck/agent-analytics/verify`.

```diff
- void trackVisit(req, { analytics, verifyIdentity: true })
+ import { verifyRequest } from '@apideck/agent-analytics/verify'
+ void trackVisit(req, { analytics, verify: verifyRequest })
```

**Caller `properties` no longer override computed fields.** They were spread
last, so `properties: { path }` silently replaced the real path and
`properties: { is_ai_bot }` could contradict the classification on the same
event. Non-colliding keys are unaffected.

**Headless automation is labelled `Headless`, not `Browser`.** A browser user
agent with headless headers accounted for 79% of one production site's agent
traffic, and calling it `Browser` hid it behind the obvious
`bot_name != 'Browser'` filter. `headless_score` and `headless_likely` are now
omitted on declared crawlers and HTTP clients, where they fired on 99% of
events and carried no signal.

**Node 18 is no longer supported; the minimum is Node 20.** `globalThis.crypto`
only became available by default in Node 19, and shipping a `node:crypto`
fallback would mean a static import of a Node builtin in a library whose main
target is edge runtimes. Node 18 reached end of life in April 2025. Runtimes
without Web Crypto now fail with an explicit message rather than a confusing
`undefined` dereference.

### Also in 0.12

- Adapters surface non-2xx responses as `CaptureTransportError` instead of
swallowing them. Pass `onError` to `trackVisit` to see them; capture still
never throws into the response path.
- Outbound captures carry a 3s `AbortSignal` (`timeoutMs` to change it).
- Root bundle is 65% smaller (27.7 kB β†’ 9.6 kB, 3.8 kB gzipped).

## Install

```bash
Expand Down
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@apideck/agent-analytics",
"version": "0.11.0",
"version": "0.12.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 Expand Up @@ -38,6 +38,11 @@
"import": "./dist/markdown.js",
"require": "./dist/markdown.cjs"
},
"./verify": {
"types": "./dist/verify.d.ts",
"import": "./dist/verify.js",
"require": "./dist/verify.cjs"
},
"./posthog": {
"types": "./dist/adapters/posthog.d.ts",
"import": "./dist/adapters/posthog.js",
Expand Down Expand Up @@ -72,7 +77,7 @@
"vitest": "^2.1.0"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"publishConfig": {
"access": "public"
Expand Down
23 changes: 21 additions & 2 deletions src/adapters/posthog.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { AnalyticsAdapter, CaptureEvent } from '../types.js'
import { CaptureTransportError } from '../errors.js'


export interface PostHogAdapterConfig {
/** PostHog project API key (the public one used by the JS SDK). */
Expand All @@ -19,6 +21,12 @@ export interface PostHogAdapterConfig {
* that need a pinned fetch).
*/
fetchImpl?: typeof fetch
/**
* Abort the capture after this many milliseconds. Defaults to 3000. Without
* a bound, a hung backend leaves a pending promise for the lifetime of an
* edge invocation.
*/
timeoutMs?: number
}

/**
Expand All @@ -43,12 +51,23 @@ export function posthogAnalytics(config: PostHogAdapterConfig): AnalyticsAdapter
timestamp: event.timestamp,
properties: event.properties
}
await fetchImpl(endpoint, {
// A 401 from a mistyped key used to look identical to success. Surface
// it: `trackVisit` routes it to `onError` and still never throws into
// the response path.
const res = await fetchImpl(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true
keepalive: true,
signal: AbortSignal.timeout(config.timeoutMs ?? 3000)
})
if (!res.ok) {
throw new CaptureTransportError(
`PostHog capture failed: ${res.status} ${res.statusText}`,
res.status,
await res.text().catch(() => undefined)
)
}
}
}
}
15 changes: 13 additions & 2 deletions src/adapters/webhook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AnalyticsAdapter, CaptureEvent } from '../types.js'
import { CaptureTransportError } from '../errors.js'

export interface WebhookAdapterConfig {
/** Destination URL that receives a POST for each event. */
Expand All @@ -12,6 +13,8 @@ export interface WebhookAdapterConfig {
transform?: (event: CaptureEvent) => unknown
/** Override the `fetch` implementation. */
fetchImpl?: typeof fetch
/** Abort the capture after this many milliseconds. Defaults to 3000. */
timeoutMs?: number
}

/**
Expand All @@ -26,15 +29,23 @@ export function webhookAnalytics(config: WebhookAdapterConfig): AnalyticsAdapter

return {
async capture(event: CaptureEvent): Promise<void> {
await fetchImpl(config.url, {
const res = await fetchImpl(config.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(config.headers ?? {})
},
body: JSON.stringify(transform(event)),
keepalive: true
keepalive: true,
signal: AbortSignal.timeout(config.timeoutMs ?? 3000)
})
if (!res.ok) {
throw new CaptureTransportError(
`Webhook capture failed: ${res.status} ${res.statusText}`,
res.status,
await res.text().catch(() => undefined)
)
}
}
}
}
8 changes: 7 additions & 1 deletion src/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,9 +312,15 @@ export function classifyRequest(req: Request): AgentClassification {
const headless = detectHeadless(req)

let kind = base.kind
let label = base.label
if (kind === 'browser' && headless.likely) {
kind = 'headless-likely'
// Relabel too. Leaving it as 'Browser' meant automation with a spoofed
// browser UA β€” 79% of one production site's agent traffic β€” was
// indistinguishable from a human in any `bot_name` breakdown, and was
// silently excluded by the obvious `bot_name != 'Browser'` filter.
label = 'Headless'
}

return { ...base, kind, headless }
return { ...base, kind, label, headless }
}
39 changes: 31 additions & 8 deletions src/cidr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,15 @@ interface V6Range {
}

export interface CompiledRanges {
v4: V4Range[]
/**
* IPv4 ranges bucketed by first octet. A vendor can publish hundreds of
* prefixes (OpenAI: 372) and a linear scan walked all of them on every
* request; bucketing turns the common case into one map lookup plus a
* handful of comparisons. Prefixes shorter than /8 span several buckets and
* are held in `v4Wide`, which stays tiny.
*/
v4: Map<number, V4Range[]>
v4Wide: V4Range[]
v6: V6Range[]
}

Expand All @@ -94,7 +102,8 @@ export interface CompiledRanges {
* shouldn't take down the whole check.
*/
export function compileRanges(cidrs: readonly string[]): CompiledRanges {
const v4: V4Range[] = []
const v4 = new Map<number, V4Range[]>()
const v4Wide: V4Range[] = []
const v6: V6Range[] = []
for (const cidr of cidrs) {
const slash = cidr.lastIndexOf('/')
Expand All @@ -114,10 +123,18 @@ export function compileRanges(cidrs: readonly string[]): CompiledRanges {
if (net === null) continue
// `bits === 0` needs special handling: `<<32` is a no-op in JS, not zero.
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
v4.push({ net: (net & mask) >>> 0, mask })
const entry = { net: (net & mask) >>> 0, mask }
if (bits >= 8) {
const bucket = entry.net >>> 24
const list = v4.get(bucket)
if (list) list.push(entry)
else v4.set(bucket, [entry])
} else {
v4Wide.push(entry)
}
}
}
return { v4, v6 }
return { v4, v4Wide, v6 }
}

/** True when `ip` falls inside any range in the pre-compiled set. */
Expand All @@ -134,7 +151,7 @@ export function ipInRanges(ip: string, ranges: CompiledRanges): boolean {
const V4_MAPPED_PREFIX = 0xffffn << 32n
if ((value >> 32n) === V4_MAPPED_PREFIX >> 32n) {
const low = Number(value & 0xffffffffn) >>> 0
if (matchV4(low, ranges.v4)) return true
if (matchV4(low, ranges)) return true
}
for (const r of ranges.v6) {
if (r.bits === 0) return true
Expand All @@ -145,11 +162,17 @@ export function ipInRanges(ip: string, ranges: CompiledRanges): boolean {

const value = ipv4ToInt(trimmed)
if (value === null) return false
return matchV4(value, ranges.v4)
return matchV4(value, ranges)
}

function matchV4(value: number, list: readonly V4Range[]): boolean {
for (const r of list) {
function matchV4(value: number, ranges: CompiledRanges): boolean {
const bucket = ranges.v4.get(value >>> 24)
if (bucket) {
for (const r of bucket) {
if (((value & r.mask) >>> 0) === r.net) return true
}
}
for (const r of ranges.v4Wide) {
if (((value & r.mask) >>> 0) === r.net) return true
}
return false
Expand Down
11 changes: 11 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/** Thrown when the analytics backend rejects, errors, or times out a capture. */
export class CaptureTransportError extends Error {
readonly status: number | undefined
readonly body: string | undefined
constructor(message: string, status?: number, body?: string) {
super(message)
this.name = 'CaptureTransportError'
this.status = status
this.body = body
}
}
Loading
Loading