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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ jobs:
- run: npm install
- run: npm run typecheck
- run: npm run build

# The root entry ships in every consumer's edge middleware. It silently
# doubled once — 9.6 kB to 22.5 kB — because optional surfaces were
# exported from the root by reflex, and nothing failed for weeks.
- name: Bundle budget
run: npm run size

- run: npm run test:unit

# Run separately so a composition failure is legible as one. Every bug
Expand Down
38 changes: 35 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ extra steps.
So start by counting:

```ts
import { paymentGate } from '@apideck/agent-analytics'
import { paymentGate } from '@apideck/agent-analytics/payments'
import { combinedVerifier } from '@apideck/agent-analytics/verify'

const gate = await paymentGate(req, {
Expand All @@ -125,7 +125,7 @@ When you know the number, switch to an entitlement: one 402 advertising a bulk
offer, one settlement, a reusable credential.

```ts
import { entitlementGateway } from '@apideck/agent-analytics'
import { entitlementGateway } from '@apideck/agent-analytics/payments'

const gate = await paymentGate(req, {
onTraining: 'charge',
Expand Down Expand Up @@ -258,7 +258,7 @@ every rule comes out in `log` mode and Vercel stages rule changes as drafts, so
nothing is live until you run `vercel firewall publish` yourself.

```ts
import { recommendFirewallRules, firewallScript } from '@apideck/agent-analytics'
import { recommendFirewallRules, firewallScript } from '@apideck/agent-analytics/firewall'

const rules = recommendFirewallRules(observations) // aggregate from your warehouse
console.log(firewallScript(rules)) // runnable, commented bash
Expand All @@ -282,6 +282,38 @@ privacy relays egress from hosting networks.
See [`docs/TESTING-PAYMENTS.md`](./docs/TESTING-PAYMENTS.md) for testing the
payment path end to end.

## Entry points

The root carries detection, classification, policy and capture — what every
consumer needs. Everything optional lives behind a subpath, so it only reaches
your bundle if you import it.

| Import | Contains | Root bundle cost |
| --- | --- | ---: |
| `@apideck/agent-analytics` | detection, classification, `agentPolicy`, `trackVisit` | 11.6 kB / **4.5 kB gz** |
| `…/verify` | Web Bot Auth + published IP range tables | 19.0 kB |
| `…/payments` | 402 challenges, gateways, entitlements | 10.9 kB |
| `…/firewall` | WAF rule recommendations (offline tool) | 6.8 kB |
| `…/markdown` | Markdown-twin content negotiation | 2.0 kB |

This split is load-bearing rather than tidy-minded. Exporting the payment and
firewall surfaces from the root once pushed it from 9.6 kB to 22.5 kB — every
site paid for a firewall recommender that will never run in middleware. Nothing
failed; the number just drifted for weeks until someone looked.

So CI now enforces it. `npm run size` checks each entry against
[`size-budget.json`](./size-budget.json) and fails the build on a regression:

```
entry gzipped budget used
dist/index.js 4.44 kB 4.88 kB 91%
dist/verify.js 6.25 kB 7.42 kB 84%
dist/pay.js 4.05 kB 4.49 kB 90%
```

Raising a budget is deliberate — `npm run size -- --update`, and say why in the
commit.

## Install

```bash
Expand Down
8 changes: 4 additions & 4 deletions docs/TESTING-PAYMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ npm i @apideck/agent-analytics

```js
// pay.mjs
import { paymentGate, entitlementGateway, memoryEntitlementStore } from '@apideck/agent-analytics'
import { paymentGate, entitlementGateway, memoryEntitlementStore } from '@apideck/agent-analytics/payments'
import { combinedVerifier } from '@apideck/agent-analytics/verify'

const store = memoryEntitlementStore({ lic_abc: { id: 'lic_abc', remaining: 3 } })
Expand Down Expand Up @@ -72,7 +72,7 @@ prove:** that any real agent understands the challenge.
### Inspect the challenge

```js
import { paymentRequired } from '@apideck/agent-analytics'
import { paymentRequired } from '@apideck/agent-analytics/payments'

const res = paymentRequired({
challenges: [
Expand Down Expand Up @@ -101,7 +101,7 @@ Wire the gate into middleware, then drive it with user agents.
```ts
// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { paymentGate, entitlementGateway } from '@apideck/agent-analytics'
import { paymentGate, entitlementGateway } from '@apideck/agent-analytics/payments'
import { combinedVerifier } from '@apideck/agent-analytics/verify'

const gateway = entitlementGateway({
Expand Down Expand Up @@ -206,7 +206,7 @@ Stripe's SDK generates challenges and settles; wrap it rather than
reimplementing.

```ts
import { mppxGateway } from '@apideck/agent-analytics'
import { mppxGateway } from '@apideck/agent-analytics/payments'

const mppx = Mppx.create({ methods: [...], secretKey })
const handler = Mppx.compose(
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 13 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.15.0",
"version": "0.16.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 @@ -43,6 +43,16 @@
"import": "./dist/verify.js",
"require": "./dist/verify.cjs"
},
"./payments": {
"types": "./dist/pay.d.ts",
"import": "./dist/pay.js",
"require": "./dist/pay.cjs"
},
"./firewall": {
"types": "./dist/firewall.d.ts",
"import": "./dist/firewall.js",
"require": "./dist/firewall.cjs"
},
"./posthog": {
"types": "./dist/adapters/posthog.d.ts",
"import": "./dist/adapters/posthog.js",
Expand Down Expand Up @@ -70,7 +80,8 @@
"test:integration": "vitest run test/integration.test.ts",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build"
"prepublishOnly": "npm run build",
"size": "node scripts/check-size.mjs"
},
"devDependencies": {
"@types/node": "^20.14.0",
Expand Down
94 changes: 94 additions & 0 deletions scripts/check-size.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Bundle budget check.
*
* The root entry is edge middleware code — it runs on every request of every
* consumer, so its size is a feature, not a vanity metric.
*
* This exists because the number silently doubled. #21 cut the root from 27.7 kB
* to 9.6 kB; over the following commits payments, gateway, entitlement and
* firewall were each exported from the root by reflex, and it climbed back to
* 22.5 kB. Nothing failed. Every test passed. It was caught weeks later while
* fetching a figure for a marketing page.
*
* So: assert the invariant rather than trusting anyone to remember. Zero
* dependencies, in keeping with the package.
*
* node scripts/check-size.mjs # check
* node scripts/check-size.mjs --update # rewrite budgets to current + headroom
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { gzipSync } from 'node:zlib'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'

const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const BUDGET_FILE = join(root, 'size-budget.json')

/** Headroom applied by --update. Tight enough that a real regression trips it. */
const HEADROOM = 1.1

function gzipped(path) {
return gzipSync(readFileSync(path), { level: 9 }).length
}

function fmt(n) {
return `${(n / 1024).toFixed(2)} kB`
}

if (!existsSync(BUDGET_FILE)) {
console.error(`No ${BUDGET_FILE}. Run with --update to create one.`)
process.exit(1)
}

const budgets = JSON.parse(readFileSync(BUDGET_FILE, 'utf8'))
const update = process.argv.includes('--update')

const rows = []
let failed = false

for (const [file, entry] of Object.entries(budgets.entries)) {
const path = join(root, file)
if (!existsSync(path)) {
console.error(`missing build output: ${file} — run \`npm run build\` first`)
process.exit(1)
}
const actual = gzipped(path)
const limit = entry.gzipBudget
const pct = Math.round((actual / limit) * 100)
const over = actual > limit
if (over) failed = true
rows.push({ file, actual, limit, pct, over, note: entry.note })
if (update) entry.gzipBudget = Math.ceil((actual * HEADROOM) / 10) * 10
}

const width = Math.max(...rows.map((r) => r.file.length))
console.log('')
console.log(`${'entry'.padEnd(width)} ${'gzipped'.padStart(9)} ${'budget'.padStart(9)} used`)
console.log('-'.repeat(width + 32))
for (const r of rows) {
const flag = r.over ? ' OVER' : ''
console.log(
`${r.file.padEnd(width)} ${fmt(r.actual).padStart(9)} ${fmt(r.limit).padStart(9)} ${String(r.pct).padStart(3)}%${flag}`
)
}
console.log('')

if (update) {
writeFileSync(BUDGET_FILE, JSON.stringify(budgets, null, 2) + '\n')
console.log(`Budgets rewritten to current + ${Math.round((HEADROOM - 1) * 100)}% headroom.`)
process.exit(0)
}

if (failed) {
console.error('Bundle budget exceeded.\n')
console.error('This is usually one of two things:')
console.error(' 1. Something optional got exported from the root entry. Check src/index.ts —')
console.error(' payments, firewall and verify belong behind subpaths, not in every')
console.error(' consumer\'s edge bundle.')
console.error(' 2. The growth is genuinely warranted. Then raise the budget deliberately:')
console.error(' node scripts/check-size.mjs --update, and say why in the commit.\n')
process.exit(1)
}

console.log('All entries within budget.')
25 changes: 25 additions & 0 deletions size-budget.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"_comment": "Gzipped byte budgets per entry point. Enforced by scripts/check-size.mjs in CI. Raise deliberately with `node scripts/check-size.mjs --update` and justify it in the commit — the root entry runs in every consumer's edge middleware on every request.",
"entries": {
"dist/index.js": {
"gzipBudget": 5000,
"note": "Root: detection, classification, agentPolicy, trackVisit. The one that matters — keep it tight."
},
"dist/verify.js": {
"gzipBudget": 7600,
"note": "Carries the published IP range tables, which grow when the weekly refresh adds prefixes. Extra headroom for that."
},
"dist/pay.js": {
"gzipBudget": 4600,
"note": "Opt-in payment surface."
},
"dist/firewall.js": {
"gzipBudget": 3200,
"note": "Offline analysis tool; never runs in middleware."
},
"dist/markdown.js": {
"gzipBudget": 1300,
"note": "Markdown-twin negotiation."
}
}
}
34 changes: 10 additions & 24 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
/**
* Package root: detection, classification, policy and capture.
*
* Deliberately excludes the paid-access surface and the firewall recommender.
* Both are opt-in and neither belongs in an edge bundle by default:
*
* @apideck/agent-analytics/verify identity verification + IP ranges
* @apideck/agent-analytics/payments 402 challenges, gateways, entitlements
* @apideck/agent-analytics/firewall WAF rule recommendations (offline)
*/
export { trackVisit } from './track.js'
export {
AI_BOT_PATTERN,
Expand All @@ -14,23 +24,6 @@ export type { AgentClassification, AgentKind, HeadlessDetection } from './bots.j
export { hashId, randomSecret, HashSecretError } from './hash.js'
export { CaptureTransportError } from './errors.js'
export { agentIntent, agentPolicy } from './policy.js'
export { mppxGateway, paymentGate, x402Gateway } from './gateway.js'
export { entitlementGateway, memoryEntitlementStore } from './entitlement.js'
export { firewallScript, recommendFirewallRules } from './firewall.js'
export type {
FirewallAction,
FirewallCondition,
FirewallRecommendation,
RateLimitSpec,
RecommendOptions,
TrafficObservation
} from './firewall.js'
export type {
BulkOffer,
Entitlement,
EntitlementGatewayOptions,
EntitlementStore
} from './entitlement.js'
export type {
GatewayResult,
Meter,
Expand All @@ -40,13 +33,6 @@ export type {
PaymentGateway,
X402GatewayOptions
} from './gateway.js'
export {
hasPaymentPayload,
paymentPayload,
paymentRequired,
respondToDecision,
withSettlement
} from './payments.js'
export type {
MppChallenge,
PaymentChallenge,
Expand Down
14 changes: 14 additions & 0 deletions src/pay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Paid-access entry point. **EXPERIMENTAL** — see `payments.ts`.
*
* Kept out of the package root deliberately. Charging is opt-in and rare;
* classification is what every consumer needs. Exporting these from the root
* put the challenge builders, the gateway and the entitlement store into every
* edge bundle whether or not the site ever charged anyone — the root grew from
* 9.6 kB to 22.5 kB before anyone noticed.
*
* import { paymentGate } from '@apideck/agent-analytics/payments'
*/
export * from './payments.js'
export * from './gateway.js'
export * from './entitlement.js'
2 changes: 2 additions & 0 deletions tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export default defineConfig({
'src/index.ts',
'src/markdown.ts',
'src/verify.ts',
'src/pay.ts',
'src/firewall.ts',
'src/adapters/posthog.ts',
'src/adapters/webhook.ts'
],
Expand Down
Loading