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