From 5486e35a5db7d3484da1ee31a7d16a15b79a989e Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Thu, 24 Sep 2026 18:28:37 -0500 Subject: [PATCH] feat: count visits AI products send (AI referrals) With an API key, protect() counts GET document navigations whose Referer (or, without one, whose campaign tags) names an AI product, by platform and landing path, and sends the totals about once a minute. Aggregate only; nothing about a visitor. A failed batch is retried under the same id, which the server counts once. Opt out with countAIReferrals: false. The platform table and the golden vectors this classifier replays are generated from WebDecoy's own classifier. --- CHANGELOG.md | 4 + packages/webdecoy/src/client.ts | 19 + .../src/referrals/llm-platforms.generated.ts | 25 ++ .../llm-referral-vectors.generated.json | 397 ++++++++++++++++++ .../src/referrals/llm-referral.test.ts | 62 +++ .../webdecoy/src/referrals/llm-referral.ts | 64 +++ .../src/referrals/referral-counter.ts | 114 +++++ packages/webdecoy/src/sdk.ts | 11 + packages/webdecoy/src/types.ts | 10 + 9 files changed, 706 insertions(+) create mode 100644 packages/webdecoy/src/referrals/llm-platforms.generated.ts create mode 100644 packages/webdecoy/src/referrals/llm-referral-vectors.generated.json create mode 100644 packages/webdecoy/src/referrals/llm-referral.test.ts create mode 100644 packages/webdecoy/src/referrals/llm-referral.ts create mode 100644 packages/webdecoy/src/referrals/referral-counter.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c878e52..09cdb9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **AI referral counting.** With an API key, the SDK now counts page visits that ChatGPT, Claude, Perplexity, Gemini, Copilot and other AI products send to your application, and reports the totals about once a minute for the AI Traffic page. Only a browser loading a page counts (a GET whose fetch metadata says it is a document navigation). What is sent is aggregate: the AI platform, the landing path and a count, never anything about the visitor. It needs an API key scoped to one site; turn it off with `countAIReferrals: false`. + ## [0.16.0] - 2026-09-24 ### Changed diff --git a/packages/webdecoy/src/client.ts b/packages/webdecoy/src/client.ts index 73ed0d2..f7169a5 100644 --- a/packages/webdecoy/src/client.ts +++ b/packages/webdecoy/src/client.ts @@ -7,6 +7,7 @@ * Workers) alike. */ +import type { AIReferralBatch } from './referrals/referral-counter'; import { SDKDetectionRequest, SDKDetectionResponse } from './types'; import type { ViolationEvent, IPEnrichmentData } from './rules/types'; @@ -157,6 +158,24 @@ export class WebDecoyClient { } } + /** + * Send AI referral counts. Resolves true when the batch needs no retry: + * accepted, or refused for a reason a retry cannot fix (a 4xx). Only a + * server error or a network failure resolves false, so the caller retries + * the same batch id. + */ + async sendAIReferrals(batch: AIReferralBatch): Promise { + try { + const response = await this.request('POST', '/api/v1/sdk/ai-referrals', batch); + return response.status < 500; + } catch (error) { + if (this.config.debug) { + console.error('[WebDecoy] Failed to send AI referrals:', error); + } + return false; + } + } + /** * Get IP enrichment data from the ingest service */ diff --git a/packages/webdecoy/src/referrals/llm-platforms.generated.ts b/packages/webdecoy/src/referrals/llm-platforms.generated.ts new file mode 100644 index 0000000..0361b41 --- /dev/null +++ b/packages/webdecoy/src/referrals/llm-platforms.generated.ts @@ -0,0 +1,25 @@ +// GENERATED from WebDecoy/app pkg/llmreferral (cmd/export-llm-platforms -ts). Do not edit. +// Referrer hostname => AI platform. + +export const LLM_PLATFORMS: Readonly> = { + "chat.deepseek.com": "DeepSeek", + "chat.openai.com": "ChatGPT", + "chatgpt.com": "ChatGPT", + "claude.ai": "Claude", + "copilot.microsoft.com": "Copilot", + "deepseek.com": "DeepSeek", + "gemini.google.com": "Gemini", + "grok.com": "Grok", + "kagi.com": "Kagi", + "meta.ai": "Meta AI", + "perplexity.ai": "Perplexity", + "phind.com": "Phind", + "www.deepseek.com": "DeepSeek", + "www.grok.com": "Grok", + "www.kagi.com": "Kagi", + "www.meta.ai": "Meta AI", + "www.perplexity.ai": "Perplexity", + "www.phind.com": "Phind", + "www.you.com": "You.com", + "you.com": "You.com", +}; diff --git a/packages/webdecoy/src/referrals/llm-referral-vectors.generated.json b/packages/webdecoy/src/referrals/llm-referral-vectors.generated.json new file mode 100644 index 0000000..6e2b880 --- /dev/null +++ b/packages/webdecoy/src/referrals/llm-referral-vectors.generated.json @@ -0,0 +1,397 @@ +[ + { + "referer": "https://chat.deepseek.com/c/123", + "page_url": "https://site.example/", + "platform": "DeepSeek" + }, + { + "referer": "https://CHAT.DEEPSEEK.COM/", + "page_url": "https://site.example/", + "platform": "DeepSeek" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=chat.deepseek.com", + "platform": "DeepSeek" + }, + { + "referer": "https://chat.openai.com/c/123", + "page_url": "https://site.example/", + "platform": "ChatGPT" + }, + { + "referer": "https://CHAT.OPENAI.COM/", + "page_url": "https://site.example/", + "platform": "ChatGPT" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=chat.openai.com", + "platform": "ChatGPT" + }, + { + "referer": "https://chatgpt.com/c/123", + "page_url": "https://site.example/", + "platform": "ChatGPT" + }, + { + "referer": "https://CHATGPT.COM/", + "page_url": "https://site.example/", + "platform": "ChatGPT" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=chatgpt.com", + "platform": "ChatGPT" + }, + { + "referer": "https://claude.ai/c/123", + "page_url": "https://site.example/", + "platform": "Claude" + }, + { + "referer": "https://CLAUDE.AI/", + "page_url": "https://site.example/", + "platform": "Claude" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=claude.ai", + "platform": "Claude" + }, + { + "referer": "https://copilot.microsoft.com/c/123", + "page_url": "https://site.example/", + "platform": "Copilot" + }, + { + "referer": "https://COPILOT.MICROSOFT.COM/", + "page_url": "https://site.example/", + "platform": "Copilot" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=copilot.microsoft.com", + "platform": "Copilot" + }, + { + "referer": "https://deepseek.com/c/123", + "page_url": "https://site.example/", + "platform": "DeepSeek" + }, + { + "referer": "https://DEEPSEEK.COM/", + "page_url": "https://site.example/", + "platform": "DeepSeek" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=deepseek.com", + "platform": "DeepSeek" + }, + { + "referer": "https://gemini.google.com/c/123", + "page_url": "https://site.example/", + "platform": "Gemini" + }, + { + "referer": "https://GEMINI.GOOGLE.COM/", + "page_url": "https://site.example/", + "platform": "Gemini" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=gemini.google.com", + "platform": "Gemini" + }, + { + "referer": "https://grok.com/c/123", + "page_url": "https://site.example/", + "platform": "Grok" + }, + { + "referer": "https://GROK.COM/", + "page_url": "https://site.example/", + "platform": "Grok" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=grok.com", + "platform": "Grok" + }, + { + "referer": "https://kagi.com/c/123", + "page_url": "https://site.example/", + "platform": "Kagi" + }, + { + "referer": "https://KAGI.COM/", + "page_url": "https://site.example/", + "platform": "Kagi" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=kagi.com", + "platform": "Kagi" + }, + { + "referer": "https://meta.ai/c/123", + "page_url": "https://site.example/", + "platform": "Meta AI" + }, + { + "referer": "https://META.AI/", + "page_url": "https://site.example/", + "platform": "Meta AI" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=meta.ai", + "platform": "Meta AI" + }, + { + "referer": "https://perplexity.ai/c/123", + "page_url": "https://site.example/", + "platform": "Perplexity" + }, + { + "referer": "https://PERPLEXITY.AI/", + "page_url": "https://site.example/", + "platform": "Perplexity" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=perplexity.ai", + "platform": "Perplexity" + }, + { + "referer": "https://phind.com/c/123", + "page_url": "https://site.example/", + "platform": "Phind" + }, + { + "referer": "https://PHIND.COM/", + "page_url": "https://site.example/", + "platform": "Phind" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=phind.com", + "platform": "Phind" + }, + { + "referer": "https://www.deepseek.com/c/123", + "page_url": "https://site.example/", + "platform": "DeepSeek" + }, + { + "referer": "https://WWW.DEEPSEEK.COM/", + "page_url": "https://site.example/", + "platform": "DeepSeek" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=www.deepseek.com", + "platform": "DeepSeek" + }, + { + "referer": "https://www.grok.com/c/123", + "page_url": "https://site.example/", + "platform": "Grok" + }, + { + "referer": "https://WWW.GROK.COM/", + "page_url": "https://site.example/", + "platform": "Grok" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=www.grok.com", + "platform": "Grok" + }, + { + "referer": "https://www.kagi.com/c/123", + "page_url": "https://site.example/", + "platform": "Kagi" + }, + { + "referer": "https://WWW.KAGI.COM/", + "page_url": "https://site.example/", + "platform": "Kagi" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=www.kagi.com", + "platform": "Kagi" + }, + { + "referer": "https://www.meta.ai/c/123", + "page_url": "https://site.example/", + "platform": "Meta AI" + }, + { + "referer": "https://WWW.META.AI/", + "page_url": "https://site.example/", + "platform": "Meta AI" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=www.meta.ai", + "platform": "Meta AI" + }, + { + "referer": "https://www.perplexity.ai/c/123", + "page_url": "https://site.example/", + "platform": "Perplexity" + }, + { + "referer": "https://WWW.PERPLEXITY.AI/", + "page_url": "https://site.example/", + "platform": "Perplexity" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=www.perplexity.ai", + "platform": "Perplexity" + }, + { + "referer": "https://www.phind.com/c/123", + "page_url": "https://site.example/", + "platform": "Phind" + }, + { + "referer": "https://WWW.PHIND.COM/", + "page_url": "https://site.example/", + "platform": "Phind" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=www.phind.com", + "platform": "Phind" + }, + { + "referer": "https://www.you.com/c/123", + "page_url": "https://site.example/", + "platform": "You.com" + }, + { + "referer": "https://WWW.YOU.COM/", + "page_url": "https://site.example/", + "platform": "You.com" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=www.you.com", + "platform": "You.com" + }, + { + "referer": "https://you.com/c/123", + "page_url": "https://site.example/", + "platform": "You.com" + }, + { + "referer": "https://YOU.COM/", + "page_url": "https://site.example/", + "platform": "You.com" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=you.com", + "platform": "You.com" + }, + { + "referer": "", + "page_url": "https://site.example/p?ref=ChatGPT", + "platform": "ChatGPT" + }, + { + "referer": "", + "page_url": "https://site.example/p?ref=Claude", + "platform": "Claude" + }, + { + "referer": "", + "page_url": "https://site.example/p?ref=Copilot", + "platform": "Copilot" + }, + { + "referer": "", + "page_url": "https://site.example/p?ref=DeepSeek", + "platform": "DeepSeek" + }, + { + "referer": "", + "page_url": "https://site.example/p?ref=Gemini", + "platform": "Gemini" + }, + { + "referer": "", + "page_url": "https://site.example/p?ref=Grok", + "platform": "Grok" + }, + { + "referer": "", + "page_url": "https://site.example/p?ref=Kagi", + "platform": "Kagi" + }, + { + "referer": "", + "page_url": "https://site.example/p?ref=Meta AI", + "platform": "Meta AI" + }, + { + "referer": "", + "page_url": "https://site.example/p?ref=Perplexity", + "platform": "Perplexity" + }, + { + "referer": "", + "page_url": "https://site.example/p?ref=Phind", + "platform": "Phind" + }, + { + "referer": "", + "page_url": "https://site.example/p?ref=You.com", + "platform": "You.com" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_medium=chatgpt.com\u0026utm_source=newsletter", + "platform": "ChatGPT" + }, + { + "referer": "https://www.google.com/", + "page_url": "https://site.example/p?utm_source=chatgpt.com", + "platform": "" + }, + { + "referer": "https://site.example/other", + "page_url": "https://site.example/p", + "platform": "" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=ai", + "platform": "" + }, + { + "referer": "", + "page_url": "https://site.example/p?utm_source=referral", + "platform": "" + }, + { + "referer": "", + "page_url": "https://site.example/p", + "platform": "" + }, + { + "referer": "not a url", + "page_url": "https://site.example/p", + "platform": "" + }, + { + "referer": "https://chatgpt.com.evil.example/", + "page_url": "https://site.example/p", + "platform": "" + } +] diff --git a/packages/webdecoy/src/referrals/llm-referral.test.ts b/packages/webdecoy/src/referrals/llm-referral.test.ts new file mode 100644 index 0000000..1032786 --- /dev/null +++ b/packages/webdecoy/src/referrals/llm-referral.test.ts @@ -0,0 +1,62 @@ +import vectors from './llm-referral-vectors.generated.json'; +import { classifyReferral } from './llm-referral'; +import { AIReferralCounter, type AIReferralBatch } from './referral-counter'; +import type { RequestMetadata } from '../types'; + +// Generated from WebDecoy's own classifier: this port must agree case for case. +describe('classifyReferral matches the shared classifier', () => { + it.each(vectors.map((v) => [v.referer || '(none)', v.page_url, v] as const))('%s -> %s', (_r, _p, v) => { + expect(classifyReferral(v.referer, v.page_url)).toBe(v.platform); + }); +}); + +describe('AIReferralCounter', () => { + const visit = (over: { referer?: string; path?: string; query?: string } = {}): RequestMetadata => ({ + method: 'GET', + path: over.path ?? '/pricing', + query: over.query, + ip: '203.0.113.9', + timestamp: Date.now(), + headers: { + 'sec-fetch-mode': 'navigate', + 'sec-fetch-dest': 'document', + ...(over.referer ? { referer: over.referer } : {}), + }, + }); + + it('counts AI page visits by platform and path, and nothing else', async () => { + const sent: AIReferralBatch[] = []; + const counter = new AIReferralCounter({ sendAIReferrals: async (b) => (sent.push(b), true) }); + counter.observe(visit({ referer: 'https://chatgpt.com/c/1' })); + counter.observe(visit({ referer: 'https://chatgpt.com/' })); + counter.observe(visit({ path: '/blog', query: 'utm_source=perplexity.ai&email=a' })); + counter.observe(visit({ referer: 'https://www.google.com/' })); + counter.observe({ ...visit({ referer: 'https://chatgpt.com/' }), method: 'POST' }); + counter.observe({ ...visit({ referer: 'https://chatgpt.com/' }), headers: { referer: 'https://chatgpt.com/' } }); + await counter.destroy(); + expect(sent).toHaveLength(1); + expect(sent[0].source).toBe('sdk'); + expect(sent[0].referrals).toEqual([ + { platform: 'ChatGPT', path: '/pricing', count: 2 }, + { platform: 'Perplexity', path: '/blog', count: 1 }, + ]); + expect(JSON.stringify(sent)).not.toMatch(/203\.0\.113|email/); + }); + + it('retries a failed batch under the same id, so it counts once', async () => { + const ids: string[] = []; + let accept = false; + const counter = new AIReferralCounter({ + sendAIReferrals: async (b) => (ids.push(b.report_id), accept), + }); + counter.observe(visit({ referer: 'https://claude.ai/' })); + await counter.flush(); + accept = true; + counter.observe(visit({ referer: 'https://claude.ai/' })); // counted into the next batch + await counter.flush(); + await counter.destroy(); + expect(ids[0]).toBe(ids[1]); + expect(ids).toHaveLength(3); + expect(ids[2]).not.toBe(ids[0]); + }); +}); diff --git a/packages/webdecoy/src/referrals/llm-referral.ts b/packages/webdecoy/src/referrals/llm-referral.ts new file mode 100644 index 0000000..649ce2a --- /dev/null +++ b/packages/webdecoy/src/referrals/llm-referral.ts @@ -0,0 +1,64 @@ +/** + * Recognises a visit an AI product sent: a Referer from an AI chat or search + * platform, or, when the browser sent no Referer, a campaign tag naming one. + * + * The platform table is generated from WebDecoy's own classifier, and this is + * a port of that classifier, pinned case for case by generated golden vectors + * (llm-referral.test.ts), so the SDK counts exactly what the dashboard's other + * sensors count. + */ +import { LLM_PLATFORMS } from './llm-platforms.generated'; + +/** Campaign parameters that can name an AI product, in precedence order. */ +const TAG_KEYS = ['utm_source', 'ref', 'utm_medium'] as const; + +/** + * The platform a referral came from, or '' when it is not an AI referral. A + * non-empty Referer decides on its own even when it names no AI product: a + * campaign tag is easy to forge and must not overrule what the browser sent. + */ +export function classifyReferral(referer: string | undefined, pageUrl: string): string { + const ref = (referer ?? '').trim(); + if (ref) { + try { + return LLM_PLATFORMS[new URL(ref).hostname.toLowerCase()] ?? ''; + } catch { + return ''; + } + } + let url: URL; + try { + url = new URL(pageUrl); + } catch { + return ''; + } + for (const key of TAG_KEYS) { + for (const value of url.searchParams.getAll(key)) { + const platform = platformForTag(value); + if (platform) return platform; + } + } + return ''; +} + +const squash = (s: string): string => s.replace(/[ \-_.]/g, ''); + +function platformForTag(raw: string): string { + let value = raw.trim().toLowerCase(); + if (!value) return ''; + try { + const host = new URL(value).hostname; + if (host) value = host.toLowerCase(); + } catch { + // Not a URL: a bare domain or a name, both handled below. + } + value = value.replace(/\.$/, ''); + if (LLM_PLATFORMS[value]) return LLM_PLATFORMS[value]; + const normalized = squash(value); + for (const [domain, name] of Object.entries(LLM_PLATFORMS)) { + if (normalized === squash(name.toLowerCase()) || normalized === domain.replace(/^www\./, '').replace(/\./g, '')) { + return name; + } + } + return ''; +} diff --git a/packages/webdecoy/src/referrals/referral-counter.ts b/packages/webdecoy/src/referrals/referral-counter.ts new file mode 100644 index 0000000..355b760 --- /dev/null +++ b/packages/webdecoy/src/referrals/referral-counter.ts @@ -0,0 +1,114 @@ +/** + * Counts visits AI products send to this application, and reports the totals. + * + * Aggregate counts only: an AI platform, a landing path and a number, sent + * about once a minute. Nothing about a visitor (no IP, user agent, cookie, + * query string or full referrer) is kept or sent. Only a browser loading a + * page counts: a GET whose fetch metadata says it is a document navigation. + * Subresources, API calls and clients that send no fetch metadata are not + * visits and are never counted. + */ +import type { RequestMetadata } from '../types'; +import { classifyReferral } from './llm-referral'; + +/** Width of the landing path WebDecoy stores. */ +const MAX_PATH = 500; + +/** Distinct platform and path pairs held before an early send. */ +const MAX_ENTRIES = 500; + +export interface AIReferralEntry { + platform: string; + path: string; + count: number; +} + +export interface AIReferralBatch { + report_id: string; + source: 'sdk'; + referrals: AIReferralEntry[]; +} + +/** What the counter needs from the API client. Resolves true when accepted. */ +export interface AIReferralSender { + sendAIReferrals(batch: AIReferralBatch): Promise; +} + +export interface AIReferralCounterConfig { + /** How often to send, in milliseconds (default 60000). */ + flushInterval?: number; +} + +const header = (headers: Record, name: string): string => { + const v = headers[name] ?? headers[name.toLowerCase()]; + if (v !== undefined) return v; + for (const [k, value] of Object.entries(headers)) if (k.toLowerCase() === name) return value; + return ''; +}; + +export class AIReferralCounter { + private counts = new Map(); + /** A batch that failed to send, retried under the same id so it counts once. */ + private pending: AIReferralBatch | null = null; + private flushing = false; + private timer: ReturnType; + + constructor( + private readonly sender: AIReferralSender, + config: AIReferralCounterConfig = {} + ) { + this.timer = setInterval(() => void this.flush(), config.flushInterval ?? 60_000); + this.timer.unref?.(); + } + + /** Count the request if it is a page visit an AI product sent. Never throws. */ + observe(metadata: RequestMetadata): void { + try { + if ((metadata.method ?? '').toUpperCase() !== 'GET') return; + const headers = metadata.headers ?? {}; + if (header(headers, 'sec-fetch-mode') !== 'navigate') return; + const dest = header(headers, 'sec-fetch-dest'); + if (dest && dest !== 'document') return; + const path = (metadata.path || '/').split('?')[0] || '/'; + const query = metadata.query ? `?${metadata.query}` : ''; + const platform = classifyReferral(header(headers, 'referer'), `https://site.invalid${path}${query}`); + if (!platform) return; + const landing = path.length > MAX_PATH ? path.slice(0, MAX_PATH) : path; + const key = `${platform}\n${landing}`; + const entry = this.counts.get(key); + if (entry) { + entry.count++; + } else { + this.counts.set(key, { platform, path: landing, count: 1 }); + if (this.counts.size >= MAX_ENTRIES) void this.flush(); + } + } catch { + // Counting must never affect the request. + } + } + + /** Send what has been counted. Never rejects. */ + async flush(): Promise { + if (this.flushing) return; + this.flushing = true; + try { + if (!this.pending && this.counts.size > 0) { + this.pending = { report_id: crypto.randomUUID(), source: 'sdk', referrals: [...this.counts.values()] }; + this.counts = new Map(); + } + if (this.pending && (await this.sender.sendAIReferrals(this.pending))) { + this.pending = null; + } + } catch { + // Kept in `pending` and retried under the same id at the next flush. + } finally { + this.flushing = false; + } + } + + /** Stop the timer and send what is left. */ + async destroy(): Promise { + clearInterval(this.timer); + await this.flush(); + } +} diff --git a/packages/webdecoy/src/sdk.ts b/packages/webdecoy/src/sdk.ts index be8ff15..09a2b00 100644 --- a/packages/webdecoy/src/sdk.ts +++ b/packages/webdecoy/src/sdk.ts @@ -8,6 +8,7 @@ import { analyzeRequest } from './local-analysis'; import { RuleEngine } from './rules/rule-engine'; import { tripwire } from './rules'; import { ViolationReporter } from './violation-reporter'; +import { AIReferralCounter } from './referrals/referral-counter'; import { IPEnrichmentClient } from './ip-enrichment'; import { AgentVerifier } from './agent/verifier'; import type { AgentRequestInput, AgentVerdict } from './agent/types'; @@ -40,6 +41,7 @@ export class WebDecoy { apiKey?: string; }; private ruleEngine: RuleEngine | null; + private referralCounter: AIReferralCounter | null = null; private violationReporter: { report(violations: ViolationEvent[]): void; flush(): Promise; destroy(): Promise } | null = null; private ipEnrichmentClient: IPEnrichmentClient | null = null; private _hasFilterRules = false; @@ -75,6 +77,7 @@ export class WebDecoy { timeout: config.timeout ?? 5000, debug: config.debug ?? false, tlsRejectUnauthorized: config.tlsRejectUnauthorized ?? true, + countAIReferrals: config.countAIReferrals ?? true, }; this.log = resolveLogger(config.logger, this.config.debug); @@ -142,6 +145,12 @@ export class WebDecoy { this.ipEnrichmentClient = new IPEnrichmentClient(this.client); } + // AI referral counting, on by default wherever there is an API key to + // report with. Aggregate counts only; see AIReferralCounter. + if (this.client && this.config.countAIReferrals !== false) { + this.referralCounter = new AIReferralCounter(this.client); + } + // Auto-create violation reporter when apiKey + rules are both present if (this.client && this.ruleEngine) { const reporter = new ViolationReporter(this.client, { @@ -348,6 +357,7 @@ export class WebDecoy { // present on every outcome — and a per-return copy is a line someone would // eventually forget on the branch that mattered. const edge = readEdgeVerdict(metadata.headers); + this.referralCounter?.observe(metadata); const span = startSpan(this.tracer, 'webdecoy.protect'); try { @@ -706,6 +716,7 @@ export class WebDecoy { if (this.violationReporter) { await this.violationReporter.destroy(); } + await this.referralCounter?.destroy(); } } diff --git a/packages/webdecoy/src/types.ts b/packages/webdecoy/src/types.ts index 4fb4686..5101c94 100644 --- a/packages/webdecoy/src/types.ts +++ b/packages/webdecoy/src/types.ts @@ -56,6 +56,16 @@ export interface WebDecoyConfig { */ debug?: boolean; + /** + * Count visits AI products send to this application (ChatGPT, Claude, + * Perplexity and others), for the AI Traffic page. Aggregate counts only: + * the AI platform, the landing path and a number, sent about once a + * minute. Nothing about a visitor is kept or sent. Needs an API key scoped + * to one site. + * @default true when an apiKey is set + */ + countAIReferrals?: boolean; + /** * Whether to reject unauthorized TLS certificates * Set to false for development/testing with self-signed certificates