@aranx/ai-analytics 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aranx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,157 @@
1
- # Temporary Holding Version
1
+ # @aranx/ai-analytics
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Records AI traffic on your site and sends it to [Aranx](https://aranx.com):
4
+
5
+ - **Bot visits.** Requests from AI search bots (`OAI-SearchBot`,
6
+ `Claude-SearchBot`, `PerplexityBot`) and from AI apps that open a page while
7
+ they answer a question (`ChatGPT-User`, `Claude-User`, `Perplexity-User`).
8
+ Training crawlers such as `GPTBot` are not recorded.
9
+ - **Visitors from AI apps.** People who open your site from a link in
10
+ ChatGPT, Perplexity, Gemini, Claude, or Copilot.
11
+
12
+ AI bots do not run JavaScript, so only server code can see their visits. The
13
+ package runs in your server's request path and sends nothing for ordinary
14
+ traffic.
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ npm install @aranx/ai-analytics
20
+ ```
21
+
22
+ Copy the traffic key from **AI visibility** in Aranx and set it as the
23
+ `ARANX_TRAFFIC_KEY` environment variable (or Worker secret). Keep it on the
24
+ server. Without a key, the package does nothing.
25
+
26
+ ## Next.js
27
+
28
+ ```ts
29
+ // proxy.ts (middleware.ts before Next.js 16)
30
+ import { withAiTraffic } from '@aranx/ai-analytics/next';
31
+
32
+ export const proxy = withAiTraffic();
33
+
34
+ // Skip static files.
35
+ export const config = {
36
+ matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
37
+ };
38
+ ```
39
+
40
+ To keep an existing proxy, wrap it: `withAiTraffic(myProxy)`.
41
+
42
+ ## Cloudflare Workers and Shopify Hydrogen (Oxygen)
43
+
44
+ ```ts
45
+ import { withAiTraffic } from '@aranx/ai-analytics/fetch';
46
+
47
+ export default {
48
+ fetch: withAiTraffic(async (request, env, ctx) => handle(request, env, ctx))
49
+ };
50
+ ```
51
+
52
+ The key comes from the `ARANX_TRAFFIC_KEY` binding. In Hydrogen, wrap the
53
+ `fetch` handler in `server.ts`.
54
+
55
+ ## Hono
56
+
57
+ ```ts
58
+ import { aiTraffic } from '@aranx/ai-analytics/hono';
59
+
60
+ app.use(aiTraffic());
61
+ ```
62
+
63
+ ## Express
64
+
65
+ ```ts
66
+ import { aiTraffic } from '@aranx/ai-analytics/express';
67
+
68
+ app.use(aiTraffic());
69
+ ```
70
+
71
+ Express keeps one process between requests, so events go out in batches
72
+ every 10 seconds. Set `app.set('trust proxy', true)` behind a load balancer
73
+ so the client IP is right.
74
+
75
+ ## Other frameworks
76
+
77
+ Any framework with a standard `Request` (React Router, Remix, SvelteKit,
78
+ Astro, Deno, Bun) can call the tracker after it builds the response:
79
+
80
+ ```ts
81
+ import { createAiTraffic } from '@aranx/ai-analytics';
82
+ import { trackRequest } from '@aranx/ai-analytics/fetch';
83
+
84
+ const tracker = createAiTraffic();
85
+
86
+ // SvelteKit hooks.server.ts
87
+ export async function handle({ event, resolve }) {
88
+ const response = await resolve(event);
89
+ void trackRequest(tracker, event.request, response);
90
+ return response;
91
+ }
92
+ ```
93
+
94
+ Fastify and Koa can pass the Node request to `nodeRequestInfo` from
95
+ `@aranx/ai-analytics/express` and then call `tracker.track(info)`.
96
+
97
+ ## Other languages
98
+
99
+ Any server can post events to the endpoint directly:
100
+
101
+ ```http
102
+ POST https://aranx.com/api/v1/ai-traffic/events
103
+ Authorization: Bearer arx_tk_...
104
+ Content-Type: application/json
105
+
106
+ {
107
+ "events": [
108
+ {
109
+ "type": "bot_visit",
110
+ "url": "https://example.com/pricing",
111
+ "user_agent": "Mozilla/5.0 (compatible; ChatGPT-User/1.0)",
112
+ "status": 200,
113
+ "occurred_at": "2026-11-03T10:00:00Z"
114
+ },
115
+ {
116
+ "type": "ai_referral",
117
+ "url": "https://example.com/blog/post?utm_source=chatgpt.com",
118
+ "referrer": "https://chatgpt.com/",
119
+ "occurred_at": "2026-11-03T10:00:05Z"
120
+ }
121
+ ]
122
+ }
123
+ ```
124
+
125
+ - `status` and `occurred_at` are optional. Aranx uses the time it receives an
126
+ event when `occurred_at` is missing, and ignores events older than 3 days.
127
+ - Each request takes 1 to 500 events and at most 512 KiB. `url` and
128
+ `referrer` take at most 2,048 characters.
129
+ - Aranx classifies each event again and ignores events it cannot place, such
130
+ as an unknown user agent or a host the website does not own.
131
+ - The response is `202` with `{ "accepted": n, "ignored": n }`. Other
132
+ responses are `400` (bad body), `401` (bad key), `403` (subscription
133
+ inactive), `413` (body too large), and `429` (over 1,200 requests per minute,
134
+ retry after 60 seconds).
135
+
136
+ ## Options
137
+
138
+ ```ts
139
+ createAiTraffic({
140
+ key: 'arx_tk_...', // default: ARANX_TRAFFIC_KEY
141
+ endpoint: 'https://aranx.com/api/v1/ai-traffic/events', // or ARANX_TRAFFIC_ENDPOINT
142
+ batch: { maxEvents: 100, intervalMs: 10_000 }, // default: off, on for Express
143
+ timeoutMs: 5_000,
144
+ onError: (error) => {} // default: one console warning per minute
145
+ });
146
+ ```
147
+
148
+ ## What is sent
149
+
150
+ For each AI request: the page URL without its query (only `utm_source` is
151
+ kept), the user agent, the response status when known, and the time. Bot
152
+ visits also carry the client IP. Aranx does not use or store it yet. It is
153
+ reserved for checking the IP ranges that the AI companies publish. Visitor requests carry the referrer, and no IP or
154
+ cookie.
155
+
156
+ Tracking never throws and never delays your response. A failed send is
157
+ dropped.
@@ -0,0 +1,37 @@
1
+ import type { AiTrafficOptions } from '../client.js';
2
+ import type { AiTrafficRequestInfo } from '../events.js';
3
+ type HeaderValue = string | string[] | undefined;
4
+ /** The parts of a Node `IncomingMessage` (or Express request) it reads. */
5
+ export interface NodeLikeRequest {
6
+ method?: string;
7
+ url?: string;
8
+ /** Express keeps the full path here when a router strips a prefix. */
9
+ originalUrl?: string;
10
+ /** Express sets it from `X-Forwarded-Proto` when `trust proxy` is on. */
11
+ protocol?: string;
12
+ headers: Record<string, HeaderValue>;
13
+ socket?: {
14
+ remoteAddress?: string;
15
+ encrypted?: boolean;
16
+ };
17
+ }
18
+ export interface NodeLikeResponse {
19
+ statusCode: number;
20
+ once(event: 'finish', listener: () => void): unknown;
21
+ }
22
+ /**
23
+ * Reads the fields the tracker needs from a Node request. Use it in Fastify
24
+ * (`request.raw`) and Koa (`ctx.req`).
25
+ */
26
+ export declare function nodeRequestInfo(req: NodeLikeRequest, status?: number): AiTrafficRequestInfo;
27
+ /**
28
+ * Express or Connect middleware. It records each request after the response
29
+ * finishes and sends events in batches, because the server process lives
30
+ * between requests.
31
+ *
32
+ * ```ts
33
+ * app.use(aiTraffic());
34
+ * ```
35
+ */
36
+ export declare function aiTraffic(options?: AiTrafficOptions): (req: NodeLikeRequest, res: NodeLikeResponse, next: () => void) => void;
37
+ export {};
@@ -0,0 +1,53 @@
1
+ import { trackerFor } from './shared.js';
2
+ function header(req, name) {
3
+ const value = req.headers[name];
4
+ if (Array.isArray(value))
5
+ return value[0] ?? null;
6
+ return value ?? null;
7
+ }
8
+ /**
9
+ * Reads the fields the tracker needs from a Node request. Use it in Fastify
10
+ * (`request.raw`) and Koa (`ctx.req`).
11
+ */
12
+ export function nodeRequestInfo(req, status) {
13
+ const forwardedProto = header(req, 'x-forwarded-proto')?.split(',')[0];
14
+ const protocol = req.protocol ??
15
+ forwardedProto?.trim() ??
16
+ (req.socket?.encrypted ? 'https' : 'http');
17
+ const host = header(req, 'x-forwarded-host')?.split(',')[0]?.trim() ??
18
+ header(req, 'host') ??
19
+ 'localhost';
20
+ const forwardedFor = header(req, 'x-forwarded-for')?.split(',')[0]?.trim();
21
+ return {
22
+ url: `${protocol}://${host}${req.originalUrl ?? req.url ?? '/'}`,
23
+ method: req.method,
24
+ userAgent: header(req, 'user-agent'),
25
+ referrer: header(req, 'referer'),
26
+ ip: header(req, 'cf-connecting-ip') ??
27
+ forwardedFor ??
28
+ req.socket?.remoteAddress ??
29
+ null,
30
+ fetchDest: header(req, 'sec-fetch-dest'),
31
+ status
32
+ };
33
+ }
34
+ /**
35
+ * Express or Connect middleware. It records each request after the response
36
+ * finishes and sends events in batches, because the server process lives
37
+ * between requests.
38
+ *
39
+ * ```ts
40
+ * app.use(aiTraffic());
41
+ * ```
42
+ */
43
+ export function aiTraffic(options = {}) {
44
+ const tracker = trackerFor({ batch: true, ...options });
45
+ return (req, res, next) => {
46
+ if (tracker.enabled) {
47
+ res.once('finish', () => {
48
+ void tracker.track(nodeRequestInfo(req, res.statusCode));
49
+ });
50
+ }
51
+ next();
52
+ };
53
+ }
@@ -0,0 +1,19 @@
1
+ import type { AiTrafficTracker } from '../client.js';
2
+ import { type AiTrafficOptionsInput, type WaitUntilContext } from './shared.js';
3
+ export { fetchRequestInfo, type WaitUntilContext } from './shared.js';
4
+ /**
5
+ * Records a Fetch API request. Use it in any framework that exposes the
6
+ * standard `Request`: React Router, Remix, SvelteKit, Astro, Hydrogen, Deno,
7
+ * and Bun.
8
+ */
9
+ export declare function trackRequest(tracker: AiTrafficTracker, request: Request, response?: Response): Promise<void>;
10
+ type FetchHandler<Env, Ctx> = (request: Request, env: Env, ctx: Ctx) => Response | Promise<Response>;
11
+ /**
12
+ * Wraps a Cloudflare Worker or Shopify Oxygen `fetch` handler. The key comes
13
+ * from the `ARANX_TRAFFIC_KEY` binding unless `options` sets it.
14
+ *
15
+ * ```ts
16
+ * export default { fetch: withAiTraffic(handler) };
17
+ * ```
18
+ */
19
+ export declare function withAiTraffic<Env, Ctx extends WaitUntilContext>(handler: FetchHandler<Env, Ctx>, options?: AiTrafficOptionsInput<Env>): FetchHandler<Env, Ctx>;
@@ -0,0 +1,28 @@
1
+ import { fetchRequestInfo, resolveOptions, trackerFor } from './shared.js';
2
+ export { fetchRequestInfo } from './shared.js';
3
+ /**
4
+ * Records a Fetch API request. Use it in any framework that exposes the
5
+ * standard `Request`: React Router, Remix, SvelteKit, Astro, Hydrogen, Deno,
6
+ * and Bun.
7
+ */
8
+ export function trackRequest(tracker, request, response) {
9
+ return tracker.track(fetchRequestInfo(request, response?.status));
10
+ }
11
+ /**
12
+ * Wraps a Cloudflare Worker or Shopify Oxygen `fetch` handler. The key comes
13
+ * from the `ARANX_TRAFFIC_KEY` binding unless `options` sets it.
14
+ *
15
+ * ```ts
16
+ * export default { fetch: withAiTraffic(handler) };
17
+ * ```
18
+ */
19
+ export function withAiTraffic(handler, options) {
20
+ return async (request, env, ctx) => {
21
+ const response = await handler(request, env, ctx);
22
+ const tracker = trackerFor(resolveOptions(options, env));
23
+ if (tracker.enabled) {
24
+ ctx.waitUntil(trackRequest(tracker, request, response));
25
+ }
26
+ return response;
27
+ };
28
+ }
@@ -0,0 +1,20 @@
1
+ import { type AiTrafficOptionsInput, type WaitUntilContext } from './shared.js';
2
+ /** The parts of a Hono context the middleware reads. */
3
+ export interface HonoLikeContext<Env = unknown> {
4
+ req: {
5
+ raw: Request;
6
+ };
7
+ res: Response;
8
+ env: Env;
9
+ /** Hono throws when the runtime has no execution context, as on Node. */
10
+ readonly executionCtx: WaitUntilContext;
11
+ }
12
+ /**
13
+ * Hono middleware. On Workers the key comes from the `ARANX_TRAFFIC_KEY`
14
+ * binding, on Node from `process.env`.
15
+ *
16
+ * ```ts
17
+ * app.use(aiTraffic());
18
+ * ```
19
+ */
20
+ export declare function aiTraffic<Env = unknown>(options?: AiTrafficOptionsInput<Env>): (c: HonoLikeContext<Env>, next: () => Promise<void>) => Promise<void>;
@@ -0,0 +1,29 @@
1
+ import { fetchRequestInfo, resolveOptions, trackerFor } from './shared.js';
2
+ /**
3
+ * Hono middleware. On Workers the key comes from the `ARANX_TRAFFIC_KEY`
4
+ * binding, on Node from `process.env`.
5
+ *
6
+ * ```ts
7
+ * app.use(aiTraffic());
8
+ * ```
9
+ */
10
+ export function aiTraffic(options) {
11
+ return async (c, next) => {
12
+ await next();
13
+ const tracker = trackerFor(resolveOptions(options, c.env));
14
+ if (!tracker.enabled)
15
+ return;
16
+ const pending = tracker.track(fetchRequestInfo(c.req.raw, c.res.status));
17
+ let ctx;
18
+ try {
19
+ ctx = c.executionCtx;
20
+ }
21
+ catch {
22
+ ctx = undefined;
23
+ }
24
+ if (ctx)
25
+ ctx.waitUntil(pending);
26
+ else
27
+ void pending;
28
+ };
29
+ }
@@ -0,0 +1,16 @@
1
+ import type { AiTrafficOptions } from '../client.js';
2
+ import { type WaitUntilContext } from './shared.js';
3
+ type Proxy<Req extends Request, Ev extends WaitUntilContext, Result> = (request: Req, event: Ev) => Result | Promise<Result>;
4
+ /**
5
+ * Wraps a Next.js `proxy` (named `middleware` before Next.js 16). With no
6
+ * proxy, the request continues unchanged. The proxy runs before the page, so
7
+ * events carry no response status.
8
+ *
9
+ * ```ts
10
+ * // proxy.ts
11
+ * import { withAiTraffic } from '@aranx/ai-analytics/next';
12
+ * export const proxy = withAiTraffic();
13
+ * ```
14
+ */
15
+ export declare function withAiTraffic<Req extends Request, Ev extends WaitUntilContext, Result = undefined>(proxy?: Proxy<Req, Ev, Result>, options?: AiTrafficOptions): (request: Req, event: Ev) => Promise<Result | undefined>;
16
+ export {};
@@ -0,0 +1,21 @@
1
+ import { fetchRequestInfo, trackerFor } from './shared.js';
2
+ /**
3
+ * Wraps a Next.js `proxy` (named `middleware` before Next.js 16). With no
4
+ * proxy, the request continues unchanged. The proxy runs before the page, so
5
+ * events carry no response status.
6
+ *
7
+ * ```ts
8
+ * // proxy.ts
9
+ * import { withAiTraffic } from '@aranx/ai-analytics/next';
10
+ * export const proxy = withAiTraffic();
11
+ * ```
12
+ */
13
+ export function withAiTraffic(proxy, options = {}) {
14
+ return async (request, event) => {
15
+ const tracker = trackerFor(options);
16
+ if (tracker.enabled) {
17
+ event.waitUntil(tracker.track(fetchRequestInfo(request)));
18
+ }
19
+ return proxy ? proxy(request, event) : undefined;
20
+ };
21
+ }
@@ -0,0 +1,22 @@
1
+ import { type AiTrafficOptions, type AiTrafficTracker } from '../client.js';
2
+ import { type AiTrafficRequestInfo } from '../events.js';
3
+ /** The `waitUntil` that Workers, Vercel, Netlify, and Next.js provide. */
4
+ export interface WaitUntilContext {
5
+ waitUntil(promise: Promise<unknown>): void;
6
+ }
7
+ /** Options, or a function that builds them from the platform bindings. */
8
+ export type AiTrafficOptionsInput<Env> = AiTrafficOptions | ((env: Env) => AiTrafficOptions);
9
+ /**
10
+ * Returns one tracker per configuration. Workers pass bindings on every
11
+ * request, so a new tracker per request would drop batching and warn on
12
+ * every call.
13
+ */
14
+ export declare function trackerFor(options: AiTrafficOptions): AiTrafficTracker;
15
+ /**
16
+ * Resolves options for platforms that pass bindings (Workers, Oxygen). With
17
+ * no options, the key and endpoint come from the `ARANX_TRAFFIC_KEY` and
18
+ * `ARANX_TRAFFIC_ENDPOINT` bindings, then from `process.env`.
19
+ */
20
+ export declare function resolveOptions<Env>(input: AiTrafficOptionsInput<Env> | undefined, env: Env): AiTrafficOptions;
21
+ /** Reads the fields the tracker needs from a Fetch API request. */
22
+ export declare function fetchRequestInfo(request: Request, status?: number): AiTrafficRequestInfo;
@@ -0,0 +1,77 @@
1
+ import { createAiTraffic, readEnv } from '../client.js';
2
+ import { clientIpFromHeaders } from '../events.js';
3
+ /** Stands in for an absent `fetch` or `onError` in the cache below. */
4
+ const NONE = {};
5
+ /**
6
+ * Trackers by `fetch`, then `onError` (by function identity), then the
7
+ * remaining options. Two callers with the same key but different handlers
8
+ * get different trackers.
9
+ */
10
+ const trackers = new WeakMap();
11
+ function cacheFor(options) {
12
+ const fetchKey = options.fetch ?? NONE;
13
+ const onErrorKey = options.onError ?? NONE;
14
+ let byOnError = trackers.get(fetchKey);
15
+ if (!byOnError) {
16
+ byOnError = new WeakMap();
17
+ trackers.set(fetchKey, byOnError);
18
+ }
19
+ let cache = byOnError.get(onErrorKey);
20
+ if (!cache) {
21
+ cache = new Map();
22
+ byOnError.set(onErrorKey, cache);
23
+ }
24
+ return cache;
25
+ }
26
+ /**
27
+ * Returns one tracker per configuration. Workers pass bindings on every
28
+ * request, so a new tracker per request would drop batching and warn on
29
+ * every call.
30
+ */
31
+ export function trackerFor(options) {
32
+ const id = JSON.stringify([
33
+ options.key ?? readEnv('ARANX_TRAFFIC_KEY') ?? null,
34
+ options.endpoint ?? readEnv('ARANX_TRAFFIC_ENDPOINT') ?? null,
35
+ options.batch ?? null,
36
+ options.timeoutMs ?? null
37
+ ]);
38
+ const cache = cacheFor(options);
39
+ let tracker = cache.get(id);
40
+ if (!tracker) {
41
+ tracker = createAiTraffic(options);
42
+ cache.set(id, tracker);
43
+ }
44
+ return tracker;
45
+ }
46
+ function stringBinding(env, name) {
47
+ if (!env || typeof env !== 'object')
48
+ return undefined;
49
+ const value = env[name];
50
+ return typeof value === 'string' ? value : undefined;
51
+ }
52
+ /**
53
+ * Resolves options for platforms that pass bindings (Workers, Oxygen). With
54
+ * no options, the key and endpoint come from the `ARANX_TRAFFIC_KEY` and
55
+ * `ARANX_TRAFFIC_ENDPOINT` bindings, then from `process.env`.
56
+ */
57
+ export function resolveOptions(input, env) {
58
+ if (typeof input === 'function')
59
+ return input(env);
60
+ return {
61
+ key: stringBinding(env, 'ARANX_TRAFFIC_KEY'),
62
+ endpoint: stringBinding(env, 'ARANX_TRAFFIC_ENDPOINT'),
63
+ ...input
64
+ };
65
+ }
66
+ /** Reads the fields the tracker needs from a Fetch API request. */
67
+ export function fetchRequestInfo(request, status) {
68
+ return {
69
+ url: request.url,
70
+ method: request.method,
71
+ userAgent: request.headers.get('user-agent'),
72
+ referrer: request.headers.get('referer'),
73
+ ip: clientIpFromHeaders(request.headers),
74
+ fetchDest: request.headers.get('sec-fetch-dest'),
75
+ status
76
+ };
77
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The AI agents and AI apps that Aranx tracks. This file is the single list:
3
+ * the npm package and the Aranx server read it, and the WordPress plugin
4
+ * build will read it.
5
+ *
6
+ * Training crawlers (GPTBot, ClaudeBot, CCBot) are left out on purpose. They
7
+ * collect pages for future models and say nothing about answers today.
8
+ */
9
+ export type AiOperator = 'openai' | 'anthropic' | 'perplexity';
10
+ /**
11
+ * `search` bots build the index an AI app searches. `user` fetchers open a
12
+ * page while the app answers a person's question.
13
+ */
14
+ export type AiAgentPurpose = 'search' | 'user';
15
+ export interface AiAgent {
16
+ /** The user-agent token, also the name stored and shown. */
17
+ name: string;
18
+ operator: AiOperator;
19
+ purpose: AiAgentPurpose;
20
+ }
21
+ export declare const AI_AGENTS: readonly AiAgent[];
22
+ export type AiAppId = 'chatgpt' | 'perplexity' | 'gemini' | 'claude' | 'copilot';
23
+ export interface AiApp {
24
+ id: AiAppId;
25
+ /** Referrer hosts. A subdomain of a listed host also matches. */
26
+ hosts: readonly string[];
27
+ /** `utm_source` values the app adds to its outbound links. */
28
+ utmSources: readonly string[];
29
+ }
30
+ export declare const AI_APPS: readonly AiApp[];
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The AI agents and AI apps that Aranx tracks. This file is the single list:
3
+ * the npm package and the Aranx server read it, and the WordPress plugin
4
+ * build will read it.
5
+ *
6
+ * Training crawlers (GPTBot, ClaudeBot, CCBot) are left out on purpose. They
7
+ * collect pages for future models and say nothing about answers today.
8
+ */
9
+ export const AI_AGENTS = [
10
+ { name: 'OAI-SearchBot', operator: 'openai', purpose: 'search' },
11
+ { name: 'ChatGPT-User', operator: 'openai', purpose: 'user' },
12
+ { name: 'Claude-SearchBot', operator: 'anthropic', purpose: 'search' },
13
+ { name: 'Claude-User', operator: 'anthropic', purpose: 'user' },
14
+ { name: 'PerplexityBot', operator: 'perplexity', purpose: 'search' },
15
+ { name: 'Perplexity-User', operator: 'perplexity', purpose: 'user' }
16
+ ];
17
+ export const AI_APPS = [
18
+ {
19
+ id: 'chatgpt',
20
+ hosts: ['chatgpt.com', 'chat.openai.com'],
21
+ utmSources: ['chatgpt.com']
22
+ },
23
+ { id: 'perplexity', hosts: ['perplexity.ai'], utmSources: [] },
24
+ {
25
+ id: 'gemini',
26
+ hosts: ['gemini.google.com', 'bard.google.com'],
27
+ utmSources: []
28
+ },
29
+ { id: 'claude', hosts: ['claude.ai'], utmSources: [] },
30
+ {
31
+ id: 'copilot',
32
+ hosts: ['copilot.microsoft.com', 'copilot.cloud.microsoft'],
33
+ utmSources: []
34
+ }
35
+ ];
@@ -0,0 +1,16 @@
1
+ import { type AiAgent, type AiApp } from './catalog.js';
2
+ /** Returns the tracked AI agent named in a user-agent string. */
3
+ export declare function matchAiAgent(userAgent: string | null | undefined): AiAgent | null;
4
+ /**
5
+ * Returns the AI app a visitor came from. The app is known from the
6
+ * `utm_source` of the landing URL or from the referrer host.
7
+ */
8
+ export declare function matchAiApp(input: {
9
+ url: string;
10
+ referrer?: string | null;
11
+ }): AiApp | null;
12
+ /**
13
+ * Keeps the origin, the path, and `utm_source`. Other query parameters can
14
+ * hold personal data, such as an email address in a sign-in link.
15
+ */
16
+ export declare function sanitizeUrl(value: string): string | null;
@@ -0,0 +1,65 @@
1
+ import { AI_AGENTS, AI_APPS } from './catalog.js';
2
+ const AGENT_PATTERNS = AI_AGENTS.map((agent) => ({
3
+ agent,
4
+ // A token match, so `Claude-User` never matches inside a longer name.
5
+ pattern: new RegExp(`(?:^|[^a-z0-9-])${agent.name.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}(?:[^a-z0-9-]|$)`, 'i')
6
+ }));
7
+ /** Returns the tracked AI agent named in a user-agent string. */
8
+ export function matchAiAgent(userAgent) {
9
+ if (!userAgent)
10
+ return null;
11
+ for (const { agent, pattern } of AGENT_PATTERNS) {
12
+ if (pattern.test(userAgent))
13
+ return agent;
14
+ }
15
+ return null;
16
+ }
17
+ function parseUrl(value) {
18
+ if (!value)
19
+ return null;
20
+ try {
21
+ return new URL(value);
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ function hostMatches(host, hosts) {
28
+ return hosts.some((h) => host === h || host.endsWith(`.${h}`));
29
+ }
30
+ /**
31
+ * Returns the AI app a visitor came from. The app is known from the
32
+ * `utm_source` of the landing URL or from the referrer host.
33
+ */
34
+ export function matchAiApp(input) {
35
+ const utmSource = parseUrl(input.url)
36
+ ?.searchParams.get('utm_source')
37
+ ?.toLowerCase();
38
+ if (utmSource) {
39
+ const app = AI_APPS.find((a) => a.utmSources.includes(utmSource));
40
+ if (app)
41
+ return app;
42
+ }
43
+ const referrerHost = parseUrl(input.referrer)?.hostname.toLowerCase();
44
+ if (referrerHost) {
45
+ const app = AI_APPS.find((a) => hostMatches(referrerHost, a.hosts));
46
+ if (app)
47
+ return app;
48
+ }
49
+ return null;
50
+ }
51
+ /**
52
+ * Keeps the origin, the path, and `utm_source`. Other query parameters can
53
+ * hold personal data, such as an email address in a sign-in link.
54
+ */
55
+ export function sanitizeUrl(value) {
56
+ const url = parseUrl(value);
57
+ if (!url || (url.protocol !== 'http:' && url.protocol !== 'https:')) {
58
+ return null;
59
+ }
60
+ const utmSource = url.searchParams.get('utm_source');
61
+ const query = utmSource
62
+ ? `?${new URLSearchParams({ utm_source: utmSource }).toString()}`
63
+ : '';
64
+ return `${url.origin}${url.pathname}${query}`;
65
+ }
@@ -0,0 +1,47 @@
1
+ import { type AiTrafficEvent, type AiTrafficRequestInfo } from './events.js';
2
+ export declare const DEFAULT_ENDPOINT = "https://aranx.com/api/v1/ai-traffic/events";
3
+ /** The most events the endpoint accepts in one request. */
4
+ export declare const MAX_EVENTS_PER_REQUEST = 500;
5
+ /** The largest request body the endpoint accepts, in UTF-8 bytes. */
6
+ export declare const MAX_REQUEST_BYTES: number;
7
+ /**
8
+ * Serializes events into request bodies of at most MAX_EVENTS_PER_REQUEST
9
+ * events and TARGET_REQUEST_BYTES bytes each.
10
+ */
11
+ export declare function toRequestBodies(events: AiTrafficEvent[]): string[];
12
+ export interface AiTrafficBatchOptions {
13
+ /** Send when this many events are queued. Default 100. */
14
+ maxEvents?: number;
15
+ /** Send queued events after this delay. Default 10 seconds. */
16
+ intervalMs?: number;
17
+ }
18
+ export interface AiTrafficOptions {
19
+ /** The website's traffic key. Default: `ARANX_TRAFFIC_KEY`. */
20
+ key?: string;
21
+ /** Default: `ARANX_TRAFFIC_ENDPOINT`, then the Aranx production URL. */
22
+ endpoint?: string;
23
+ /**
24
+ * Queue events and send them together. Use it on long-running servers.
25
+ * Serverless functions and Workers keep no memory between requests, so
26
+ * they send each event at once (the default).
27
+ */
28
+ batch?: boolean | AiTrafficBatchOptions;
29
+ /** Abort a send after this delay. Default 5 seconds. */
30
+ timeoutMs?: number;
31
+ fetch?: typeof fetch;
32
+ /** Called when a send fails. Default: a console warning. */
33
+ onError?: (error: unknown) => void;
34
+ }
35
+ export interface AiTrafficTracker {
36
+ /** False when no valid key is set. Tracking then does nothing. */
37
+ readonly enabled: boolean;
38
+ /**
39
+ * Records the request when it is AI traffic. The promise never rejects.
40
+ * Pass it to `waitUntil` where the platform has one.
41
+ */
42
+ track(info: AiTrafficRequestInfo): Promise<void>;
43
+ /** Sends queued events now. */
44
+ flush(): Promise<void>;
45
+ }
46
+ export declare function readEnv(name: string): string | undefined;
47
+ export declare function createAiTraffic(options?: AiTrafficOptions): AiTrafficTracker;
package/dist/client.js ADDED
@@ -0,0 +1,156 @@
1
+ import { toAiTrafficEvent } from './events.js';
2
+ export const DEFAULT_ENDPOINT = 'https://aranx.com/api/v1/ai-traffic/events';
3
+ /** The most events the endpoint accepts in one request. */
4
+ export const MAX_EVENTS_PER_REQUEST = 500;
5
+ /** The largest request body the endpoint accepts, in UTF-8 bytes. */
6
+ export const MAX_REQUEST_BYTES = 512 * 1024;
7
+ /** The client keeps each request below this size, under the server cap. */
8
+ const TARGET_REQUEST_BYTES = 448 * 1024;
9
+ const ENVELOPE = { open: '{"events":[', close: ']}' };
10
+ const encoder = new TextEncoder();
11
+ /**
12
+ * Serializes events into request bodies of at most MAX_EVENTS_PER_REQUEST
13
+ * events and TARGET_REQUEST_BYTES bytes each.
14
+ */
15
+ export function toRequestBodies(events) {
16
+ const envelopeBytes = encoder.encode(ENVELOPE.open + ENVELOPE.close).length;
17
+ const bodies = [];
18
+ let items = [];
19
+ let bytes = envelopeBytes;
20
+ const close = () => {
21
+ bodies.push(ENVELOPE.open + items.join(',') + ENVELOPE.close);
22
+ items = [];
23
+ bytes = envelopeBytes;
24
+ };
25
+ for (const event of events) {
26
+ const item = JSON.stringify(event);
27
+ const itemBytes = encoder.encode(item).length;
28
+ if (items.length >= MAX_EVENTS_PER_REQUEST ||
29
+ (items.length > 0 && bytes + 1 + itemBytes > TARGET_REQUEST_BYTES)) {
30
+ close();
31
+ }
32
+ // A comma separates every item after the first.
33
+ bytes += itemBytes + (items.length > 0 ? 1 : 0);
34
+ items.push(item);
35
+ }
36
+ if (items.length > 0)
37
+ close();
38
+ return bodies;
39
+ }
40
+ const KEY_PATTERN = /^arx_tk_[A-Za-z0-9]{32,64}$/;
41
+ export function readEnv(name) {
42
+ const env = globalThis.process?.env;
43
+ const value = env?.[name];
44
+ return value && value.trim() ? value.trim() : undefined;
45
+ }
46
+ function defaultOnError() {
47
+ let lastWarnedAt = 0;
48
+ return (error) => {
49
+ const now = Date.now();
50
+ if (now - lastWarnedAt < 60_000)
51
+ return;
52
+ lastWarnedAt = now;
53
+ console.warn('[@aranx/ai-analytics] Could not send AI traffic:', error);
54
+ };
55
+ }
56
+ function resolveBatch(batch) {
57
+ if (!batch)
58
+ return null;
59
+ const options = batch === true ? {} : batch;
60
+ return {
61
+ maxEvents: Math.min(Math.max(1, options.maxEvents ?? 100), MAX_EVENTS_PER_REQUEST),
62
+ intervalMs: Math.max(1_000, options.intervalMs ?? 10_000)
63
+ };
64
+ }
65
+ const disabledTracker = {
66
+ enabled: false,
67
+ track: () => Promise.resolve(),
68
+ flush: () => Promise.resolve()
69
+ };
70
+ export function createAiTraffic(options = {}) {
71
+ const key = options.key ?? readEnv('ARANX_TRAFFIC_KEY');
72
+ if (!key || !KEY_PATTERN.test(key)) {
73
+ console.warn(key
74
+ ? '[@aranx/ai-analytics] The traffic key is malformed. AI traffic is not tracked.'
75
+ : '[@aranx/ai-analytics] ARANX_TRAFFIC_KEY is not set. AI traffic is not tracked.');
76
+ return disabledTracker;
77
+ }
78
+ const endpoint = options.endpoint ??
79
+ readEnv('ARANX_TRAFFIC_ENDPOINT') ??
80
+ DEFAULT_ENDPOINT;
81
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
82
+ const reportError = options.onError ?? defaultOnError();
83
+ // A throwing handler must not break the promise that tracking never
84
+ // rejects.
85
+ const onError = (error) => {
86
+ try {
87
+ reportError(error);
88
+ }
89
+ catch {
90
+ // Ignored on purpose.
91
+ }
92
+ };
93
+ const timeoutMs = options.timeoutMs ?? 5_000;
94
+ const batch = resolveBatch(options.batch);
95
+ let queue = [];
96
+ let timer = null;
97
+ async function post(body) {
98
+ try {
99
+ const response = await doFetch(endpoint, {
100
+ method: 'POST',
101
+ headers: {
102
+ Authorization: `Bearer ${key}`,
103
+ 'Content-Type': 'application/json'
104
+ },
105
+ body,
106
+ signal: AbortSignal.timeout(timeoutMs)
107
+ });
108
+ if (!response.ok) {
109
+ onError(new Error(`Aranx responded with HTTP ${response.status}`));
110
+ }
111
+ }
112
+ catch (error) {
113
+ onError(error);
114
+ }
115
+ }
116
+ async function send(events) {
117
+ for (const body of toRequestBodies(events))
118
+ await post(body);
119
+ }
120
+ function flush() {
121
+ if (timer) {
122
+ clearTimeout(timer);
123
+ timer = null;
124
+ }
125
+ if (queue.length === 0)
126
+ return Promise.resolve();
127
+ const events = queue;
128
+ queue = [];
129
+ return send(events);
130
+ }
131
+ function track(info) {
132
+ let event;
133
+ try {
134
+ event = toAiTrafficEvent(info);
135
+ }
136
+ catch (error) {
137
+ onError(error);
138
+ return Promise.resolve();
139
+ }
140
+ if (!event)
141
+ return Promise.resolve();
142
+ if (!batch)
143
+ return send([event]);
144
+ queue.push(event);
145
+ if (queue.length >= batch.maxEvents)
146
+ return flush();
147
+ if (!timer) {
148
+ timer = setTimeout(() => void flush(), batch.intervalMs);
149
+ // Node keeps a process alive for a pending timer. A queue of
150
+ // traffic counts must not.
151
+ timer.unref?.();
152
+ }
153
+ return Promise.resolve();
154
+ }
155
+ return { enabled: true, track, flush };
156
+ }
@@ -0,0 +1,45 @@
1
+ /** The longest `url` or `referrer` the endpoint accepts. */
2
+ export declare const MAX_URL_LENGTH = 2048;
3
+ /** A page request from a tracked AI agent. */
4
+ export interface BotVisitEvent {
5
+ type: 'bot_visit';
6
+ url: string;
7
+ user_agent: string;
8
+ /** HTTP status of the response, when the caller knows it. */
9
+ status?: number;
10
+ /**
11
+ * Client IP. The endpoint accepts it but does not use it yet: Aranx
12
+ * neither checks nor stores it.
13
+ */
14
+ ip?: string;
15
+ occurred_at: string;
16
+ }
17
+ /** A person who opened the site from a link in an AI app. */
18
+ export interface AiReferralEvent {
19
+ type: 'ai_referral';
20
+ url: string;
21
+ referrer?: string;
22
+ status?: number;
23
+ occurred_at: string;
24
+ }
25
+ export type AiTrafficEvent = BotVisitEvent | AiReferralEvent;
26
+ /** The request fields an adapter reads from its framework. */
27
+ export interface AiTrafficRequestInfo {
28
+ url: string;
29
+ method?: string | null;
30
+ userAgent?: string | null;
31
+ referrer?: string | null;
32
+ ip?: string | null;
33
+ status?: number | null;
34
+ /** The `Sec-Fetch-Dest` header. Browsers send `document` for pages. */
35
+ fetchDest?: string | null;
36
+ }
37
+ /**
38
+ * Turns a request into an event, or returns null when the request is not AI
39
+ * traffic. Only GET and HEAD requests count.
40
+ */
41
+ export declare function toAiTrafficEvent(info: AiTrafficRequestInfo, now?: Date): AiTrafficEvent | null;
42
+ /** Reads the client IP from the headers that hosts and CDNs set. */
43
+ export declare function clientIpFromHeaders(headers: {
44
+ get(name: string): string | null;
45
+ }): string | null;
package/dist/events.js ADDED
@@ -0,0 +1,59 @@
1
+ import { matchAiAgent, matchAiApp, sanitizeUrl } from './classify.js';
2
+ /** The longest `url` or `referrer` the endpoint accepts. */
3
+ export const MAX_URL_LENGTH = 2048;
4
+ /** The longest `ip` the endpoint accepts. A longer value is not sent. */
5
+ const MAX_IP_LENGTH = 64;
6
+ /**
7
+ * Turns a request into an event, or returns null when the request is not AI
8
+ * traffic. Only GET and HEAD requests count.
9
+ */
10
+ export function toAiTrafficEvent(info, now = new Date()) {
11
+ const method = info.method?.toUpperCase();
12
+ if (method && method !== 'GET' && method !== 'HEAD')
13
+ return null;
14
+ const url = sanitizeUrl(info.url);
15
+ // The endpoint rejects longer URLs, so such an event is not sent.
16
+ if (!url || url.length > MAX_URL_LENGTH)
17
+ return null;
18
+ const status = typeof info.status === 'number' && info.status > 0
19
+ ? info.status
20
+ : undefined;
21
+ const occurred_at = now.toISOString();
22
+ if (info.userAgent && matchAiAgent(info.userAgent)) {
23
+ return {
24
+ type: 'bot_visit',
25
+ url,
26
+ user_agent: info.userAgent.slice(0, 512),
27
+ ...(status !== undefined && { status }),
28
+ ...(info.ip && info.ip.length <= MAX_IP_LENGTH && { ip: info.ip }),
29
+ occurred_at
30
+ };
31
+ }
32
+ // Images, scripts, and API calls of a page carry the page as referrer.
33
+ // Only the page itself is the landing.
34
+ if (info.fetchDest && info.fetchDest !== 'document')
35
+ return null;
36
+ if (matchAiApp({ url: info.url, referrer: info.referrer })) {
37
+ const referrer = info.referrer ? sanitizeUrl(info.referrer) : null;
38
+ if (referrer && referrer.length > MAX_URL_LENGTH)
39
+ return null;
40
+ return {
41
+ type: 'ai_referral',
42
+ url,
43
+ ...(referrer && { referrer }),
44
+ ...(status !== undefined && { status }),
45
+ occurred_at
46
+ };
47
+ }
48
+ return null;
49
+ }
50
+ /** Reads the client IP from the headers that hosts and CDNs set. */
51
+ export function clientIpFromHeaders(headers) {
52
+ const direct = headers.get('cf-connecting-ip') ??
53
+ headers.get('true-client-ip') ??
54
+ headers.get('x-real-ip');
55
+ if (direct)
56
+ return direct.trim();
57
+ const forwarded = headers.get('x-forwarded-for');
58
+ return forwarded ? (forwarded.split(',')[0]?.trim() ?? null) : null;
59
+ }
@@ -0,0 +1,4 @@
1
+ export { AI_AGENTS, AI_APPS, type AiAgent, type AiAgentPurpose, type AiApp, type AiAppId, type AiOperator } from './catalog.js';
2
+ export { matchAiAgent, matchAiApp, sanitizeUrl } from './classify.js';
3
+ export { clientIpFromHeaders, MAX_URL_LENGTH, toAiTrafficEvent, type AiReferralEvent, type AiTrafficEvent, type AiTrafficRequestInfo, type BotVisitEvent } from './events.js';
4
+ export { createAiTraffic, DEFAULT_ENDPOINT, MAX_EVENTS_PER_REQUEST, MAX_REQUEST_BYTES, type AiTrafficBatchOptions, type AiTrafficOptions, type AiTrafficTracker } from './client.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { AI_AGENTS, AI_APPS } from './catalog.js';
2
+ export { matchAiAgent, matchAiApp, sanitizeUrl } from './classify.js';
3
+ export { clientIpFromHeaders, MAX_URL_LENGTH, toAiTrafficEvent } from './events.js';
4
+ export { createAiTraffic, DEFAULT_ENDPOINT, MAX_EVENTS_PER_REQUEST, MAX_REQUEST_BYTES } from './client.js';
package/package.json CHANGED
@@ -1,6 +1,88 @@
1
1
  {
2
- "name": "@aranx/ai-analytics",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@aranx/ai-analytics",
3
+ "version": "0.1.0",
4
+ "description": "Track AI search bots and visitors from AI apps on your site with Aranx.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/aranx-ai/blog-empire.git",
11
+ "directory": "packages/ai-analytics"
12
+ },
13
+ "homepage": "https://aranx.com",
14
+ "keywords": [
15
+ "aranx",
16
+ "ai",
17
+ "chatgpt",
18
+ "perplexity",
19
+ "claude",
20
+ "bots",
21
+ "analytics",
22
+ "geo"
23
+ ],
24
+ "engines": {
25
+ "node": ">=20.19"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md"
30
+ ],
31
+ "main": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ },
38
+ "./next": {
39
+ "types": "./dist/adapters/next.d.ts",
40
+ "default": "./dist/adapters/next.js"
41
+ },
42
+ "./fetch": {
43
+ "types": "./dist/adapters/fetch.d.ts",
44
+ "default": "./dist/adapters/fetch.js"
45
+ },
46
+ "./hono": {
47
+ "types": "./dist/adapters/hono.d.ts",
48
+ "default": "./dist/adapters/hono.js"
49
+ },
50
+ "./express": {
51
+ "types": "./dist/adapters/express.d.ts",
52
+ "default": "./dist/adapters/express.js"
53
+ },
54
+ "./package.json": "./package.json"
55
+ },
56
+ "typesVersions": {
57
+ "*": {
58
+ "next": [
59
+ "./dist/adapters/next.d.ts"
60
+ ],
61
+ "fetch": [
62
+ "./dist/adapters/fetch.d.ts"
63
+ ],
64
+ "hono": [
65
+ "./dist/adapters/hono.d.ts"
66
+ ],
67
+ "express": [
68
+ "./dist/adapters/express.d.ts"
69
+ ]
70
+ }
71
+ },
72
+ "publishConfig": {
73
+ "access": "public"
74
+ },
75
+ "scripts": {
76
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
77
+ "tsc": "tsc --noEmit",
78
+ "test": "vitest run",
79
+ "test:watch": "vitest",
80
+ "test:coverage": "vitest run --coverage"
81
+ },
82
+ "devDependencies": {
83
+ "@types/node": "^22.15.29",
84
+ "@vitest/coverage-v8": "^5.0.5",
85
+ "typescript": "^5.7.2",
86
+ "vitest": "^5.0.5"
87
+ }
88
+ }