Analytics
See who visits your site and where they came from, including which AI assistant sent them (ChatGPT, Perplexity, Gemini, Claude, Copilot…) and which AI crawlers read which pages. It all lives in Pluma: no Google Analytics, no cookies, no third parties.
You see it in Visibility in your site's dashboard, your agent sees it with get_analytics (MCP tools), and the API has it at GET /api/v1/spaces/:space_id/analytics (Management API).
There are two parts, because they see different things:
| Part | What it sees | Where it runs |
|---|---|---|
The snippet (pluma.js) |
People: pages, sources, AI assistants, UTM campaigns, country and city, device | In the visitor's browser |
| AI crawler reports | Bots: GPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot, Googlebot, bingbot and more | On your site's server. Crawlers don't run JavaScript, so a script can't count them |
Both need the site's live URL (Settings → General): Pluma only counts pages on that domain.
Add the snippet
Put these two lines before </body> on every page. Use your site's slug; the dashboard shows them ready to copy.
<script defer data-site="your-site" src="https://pluma.so/s/your-site/pluma.js"></script>
<noscript><img src="https://pluma.so/s/your-site/p.gif" alt="" width="1" height="1"></noscript>
The script counts people. The invisible image is for crawlers, which read the page without running scripts (see AI crawlers).
- It sends one small request per page and nothing else. In single-page apps it also counts each route change.
- It does nothing on
localhost. - Sites built from the Pluma template already have it.
- The easiest way: in Visibility → Traffic, press Copy for your assistant and paste it to your agent. It follows this page and installs it.
The first visit shows up in Visibility within seconds. That's how you know it works.
AI crawlers
AI crawlers don't run JavaScript: Vercel and MERJ looked at hundreds of millions of their requests and none executed it (The rise of the AI crawler). That's why no script-based analytics (Google Analytics, Plausible, this snippet) can count them. The standard way is the server's or the CDN's logs.
With the snippet alone you get a sample. Some crawlers download files from the page without running them: in that study, 11.5% of ChatGPT's fetches were JavaScript, and 24% of Claude's were JavaScript and 35% images. When a crawler downloads the snippet's script or its invisible image, Pluma records it, marked as a sample. They're mostly training crawlers: the ones that put you in answers usually fetch only the page.
For the full count, your site's server tells Pluma each time a known AI crawler asks for a page. It sends the user agent, the path and the response code to:
POST https://pluma.so/collect/bots
with the header Authorization: Bearer <a key of the site>. A delivery key is enough (create one in Settings → Keys); keep it in an environment variable called PLUMA_KEY, never in the page. The body is {"ua": "…", "path": "/menu", "status": 200}, or {"visits": [ … ]} with up to 100. Pluma answers 204 and ignores anything that isn't a known crawler.
The report goes out after the page is sent, so it doesn't slow your site down. Pick your host:
Netlify
netlify/edge-functions/pluma-bots.ts:
import type { Config, Context } from "@netlify/edge-functions";
const BOTS = /OAI-SearchBot|ChatGPT-User|GPTBot|Claude-SearchBot|Claude-User|ClaudeBot|PerplexityBot|Perplexity-User|Googlebot|GoogleOther|bingbot|DuckAssistBot|Applebot|Meta-External|MistralAI-User|Amazonbot|Bytespider|CCBot|cohere-ai/i;
export default async (request: Request, context: Context) => {
const response = await context.next();
const ua = request.headers.get("user-agent") ?? "";
if (BOTS.test(ua)) {
context.waitUntil(fetch("https://pluma.so/collect/bots", {
method: "POST",
headers: { Authorization: `Bearer ${Netlify.env.get("PLUMA_KEY")}`, "Content-Type": "application/json" },
body: JSON.stringify({ ua, path: new URL(request.url).pathname, status: response.status }),
}).catch(() => {}));
}
return response;
};
export const config: Config = { path: "/*", excludedPath: ["/_astro/*", "/*.css", "/*.js", "/*.png", "/*.jpg", "/*.svg", "/*.webp", "/*.ico"] };
Add PLUMA_KEY in Site configuration → Environment variables.
Vercel
middleware.ts at the root of the project (works with any framework on Vercel):
import { waitUntil } from "@vercel/functions";
const BOTS = /OAI-SearchBot|ChatGPT-User|GPTBot|Claude-SearchBot|Claude-User|ClaudeBot|PerplexityBot|Perplexity-User|Googlebot|GoogleOther|bingbot|DuckAssistBot|Applebot|Meta-External|MistralAI-User|Amazonbot|Bytespider|CCBot|cohere-ai/i;
export default function middleware(request: Request) {
const ua = request.headers.get("user-agent") ?? "";
if (BOTS.test(ua)) {
waitUntil(fetch("https://pluma.so/collect/bots", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.PLUMA_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ ua, path: new URL(request.url).pathname }),
}).catch(() => {}));
}
}
export const config = { matcher: "/((?!_next|_astro|.*\\.(?:css|js|png|jpg|svg|webp|ico)$).*)" };
Vercel middleware runs before the page, so it doesn't know the response code; Pluma stores it as empty.
Cloudflare Pages
functions/_middleware.ts:
const BOTS = /OAI-SearchBot|ChatGPT-User|GPTBot|Claude-SearchBot|Claude-User|ClaudeBot|PerplexityBot|Perplexity-User|Googlebot|GoogleOther|bingbot|DuckAssistBot|Applebot|Meta-External|MistralAI-User|Amazonbot|Bytespider|CCBot|cohere-ai/i;
export const onRequest: PagesFunction<{ PLUMA_KEY: string }> = async (context) => {
const response = await context.next();
const ua = context.request.headers.get("user-agent") ?? "";
if (BOTS.test(ua)) {
context.waitUntil(fetch("https://pluma.so/collect/bots", {
method: "POST",
headers: { Authorization: `Bearer ${context.env.PLUMA_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ ua, path: new URL(context.request.url).pathname, status: response.status }),
}).catch(() => {}));
}
return response;
};
Your own server
Any server works the same way: after answering a request whose user agent matches the list above, POST the visit to https://pluma.so/collect/bots without waiting for the answer.
What you see
Visibility has three views, for the last 7, 30 or 90 days:
- Traffic: visitors, page views, and how many came from AI assistants (and which one). Then where they came from (AI assistants, search engines, social, campaigns, email, direct, other sites), top pages, the sites that sent them, countries, cities, UTM campaigns and devices.
- AI crawlers: visits by crawler, who runs it and why: search (puts you in answers), user (reads a page for a person, right now) or training (collects data to train models). And which pages they read for answers.
- Readiness: whether assistants can read the site at all (AI visibility).
How a visit is classified:
- AI assistants: the referrer is chatgpt.com, perplexity.ai, gemini.google.com, claude.ai, copilot.microsoft.com and others, or the link has
utm_source=chatgpt.com(ChatGPT adds it to the links it shows). - Campaigns: the link has
utm_source. - Direct: no referrer and no UTM.
What it stores
- No cookies and nothing in the visitor's browser, so no cookie banner is needed for it.
- No IP addresses. The IP is used once, when the visit arrives: to look up country and city in a database on Pluma's own server (IP Geolocation by DB-IP, CC BY 4.0), and to make the visitor code. Then it's dropped.
- The visitor code is a hash of the site, IP and browser with a key that changes every day. A person counts once a day per site; tomorrow they're a new code, and it can't be turned back into an IP.
- Visits are kept 13 months, then deleted.
- You're the controller of your visitors' data and Pluma processes it for you: mention it in your site's privacy policy. See the Pluma privacy policy.