Track
AI crawler tracking
Count visits from ChatGPT, Claude, Perplexity and other AI crawlers in three steps: create a bot token, add a few lines to your server, check a hit arrives.
AI assistants read your pages without running JavaScript, so the ProductLense script never sees them. To count them, your server tells ProductLense each time a crawler fetches a page. ProductLense sorts every visit into one of three kinds:
| Kind | Meaning | Examples |
|---|---|---|
| Answers | A live fetch made for one person's question. The closest thing to a reader. | ChatGPT-User, Claude-User, Perplexity-User |
| Indexing | A crawl that builds the search index AI answers cite | OAI-SearchBot, Claude-SearchBot, PerplexityBot, Googlebot |
| Training | A crawl that collects model training data | GPTBot, ClaudeBot, CCBot, Google-Extended |
Crawler hits are stored without IP addresses and count against a separate allowance of 100,000 hits a month on every plan, never against your events.
Setting it up takes three steps and about five minutes:
- Create a bot token in the dashboard.
- Add a few lines to your server, for whatever your site runs on.
- Check the first hit arrives.
Your pages are served exactly as before. Only crawler requests are reported, after or beside the response, and a slow or unavailable ProductLense never slows your site down.
Step 1: Create a bot token
- In the ProductLense dashboard, open your site and go to Settings → AI crawlers.
- Click Create token. You need to be the site's owner or an admin.
- Copy the token from the window that opens. It starts with
plbot_. - Store it as an environment variable named
PL_BOT_TOKENwhere your site runs. See where to put the token.
The full token is shown only once; afterwards the dashboard shows just its first characters. If you lose it, click Rotate token: you get a new one, and the old one stops working at once, so update your server too. The token belongs to one site and only lets its holder add crawler hits, but keep it out of your code and out of git.
Step 2: Add it to your server
Pick the row that matches how your site is served:
| Your site runs on | Use |
|---|---|
| Next.js (Vercel or anywhere) | The npm package |
| Express, Hono, Cloudflare Workers, Cloudflare Pages | The npm package |
| Another JavaScript server: SvelteKit, Nuxt, Astro, Remix, Fastify, Koa | The npm package, one call |
| Django, Flask or FastAPI | Python |
| Ruby on Rails, or any Rack app | Ruby |
| WordPress or Laravel | PHP |
| Netlify | Netlify Edge Function |
| Your own server behind Nginx | Nginx |
| Webflow, Framer, Wix, Squarespace, Shopify, Ghost(Pro), GitHub Pages or another builder | Put Cloudflare in front |
Every option checks the user agent on your server first, so human visitors never cause a request to ProductLense. ProductLense checks every hit again on its side and drops anything it does not recognise.
JavaScript and TypeScript
Install the package:
npm install @productlense/ai-crawlpnpm add @productlense/ai-crawlyarn add @productlense/ai-crawlThen add the lines for your framework:
middleware.ts
import { withProductLenseCrawlers } from '@productlense/ai-crawl/next';
export const middleware = withProductLenseCrawlers({ token: process.env.PL_BOT_TOKEN });
// Already have a middleware? Pass it as the second argument:
// export const middleware = withProductLenseCrawlers({ token: process.env.PL_BOT_TOKEN }, yourMiddleware);server.js
import { productLenseCrawlers } from '@productlense/ai-crawl/express';
// before your routes
app.use(productLenseCrawlers({ token: process.env.PL_BOT_TOKEN }));src/index.ts
import { productLenseCrawlers } from '@productlense/ai-crawl/hono';
app.use('*', productLenseCrawlers((env) => ({ token: env.PL_BOT_TOKEN })));src/index.ts
import { withProductLenseCrawlers } from '@productlense/ai-crawl/cloudflare';
export default withProductLenseCrawlers(
{ fetch: (request) => fetch(request) }, // or your existing handler
(env) => ({ token: env.PL_BOT_TOKEN }),
);functions/_middleware.ts
import { productLensePagesMiddleware } from '@productlense/ai-crawl/cloudflare';
export const onRequest = productLensePagesMiddleware((env) => ({ token: env.PL_BOT_TOKEN }));
// Already have a _middleware.ts? Run both, the crawler one first:
// export const onRequest = [productLensePagesMiddleware((env) => ({ token: env.PL_BOT_TOKEN })), yourMiddleware];The same lines are under Settings → AI crawlers → Install, with a copy button. Next.js middleware runs before the page renders, so Next.js hits arrive without an HTTP status; the other adapters send the status your server answered with.
Any other JavaScript server
Call reportCrawl once per request, after you have the response. It checks the user agent itself, so it does nothing for people, and it never throws.
import { reportCrawl } from '@productlense/ai-crawl';
import { env } from '$env/dynamic/private';
export async function handle({ event, resolve }) {
const response = await resolve(event);
const report = reportCrawl(
{ token: env.PL_BOT_TOKEN },
{ path: event.url.pathname, userAgent: event.request.headers.get('user-agent'), status: response.status },
);
// On Cloudflare, keep the report alive after the response is sent (ctx, or context on older adapters)
const ctx = event.platform?.ctx ?? event.platform?.context;
ctx?.waitUntil?.(report);
return response;
}The report runs in the background, so the page goes out without waiting for it. On a long-running Node server that is all you need. On serverless hosts that stop work once the response is sent, hand the promise to the platform's waitUntil, as above for Cloudflare. Where there is none, await it before returning: that delays only crawlers, never people, and by at most 3 seconds.
Python (Django, Flask, FastAPI)
Save this file in your project. It uses only the standard library and sends in a background thread, so the response is never delayed.
"""Reports AI crawler visits to ProductLense. Needs only the standard library."""
import json
import os
import re
import threading
import urllib.request
from urllib.parse import urlsplit
ENDPOINT = "https://api.productlense.app/api/crawl"
AI_BOTS = re.compile(r"ChatGPT-User|Claude-User|Perplexity-User|MistralAI-User|DuckAssistBot|Meta-ExternalFetcher|Gemini-Deep-Research|cohere-ai|OAI-SearchBot|Claude-SearchBot|PerplexityBot|Googlebot|Bingbot|Applebot|DuckDuckBot|YouBot|Bravebot|GPTBot|ClaudeBot|anthropic-ai|Claude-Web|CCBot|Google-Extended|GoogleOther|Bytespider|Meta-ExternalAgent|FacebookBot|Amazonbot|cohere-training-data-crawler|Diffbot|AI2Bot|ImagesiftBot|Omgilibot|PanguBot|Timpibot", re.IGNORECASE)
def _send(body, token):
request = urllib.request.Request(
ENDPOINT,
data=json.dumps(body).encode(),
method="POST",
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
)
try:
urllib.request.urlopen(request, timeout=2).close()
except Exception:
pass # never let ProductLense affect your site
def report_crawl(path, user_agent, status=None):
"""Call once per request. Does nothing for people; for crawlers it sends in the background."""
token = os.environ.get("PL_BOT_TOKEN")
if not token or not user_agent or not AI_BOTS.search(user_agent[:1024]):
return
body = {"path": urlsplit(path).path or "/", "ua": user_agent}
if status:
body["status"] = status
threading.Thread(target=_send, args=(body, token), daemon=True).start()Then call it from your framework. In Django, also add "yourapp.middleware.ProductLenseCrawlers" to MIDDLEWARE in settings.py.
yourapp/middleware.py
from .productlense_crawl import report_crawl
class ProductLenseCrawlers:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
report_crawl(request.path, request.META.get("HTTP_USER_AGENT", ""), response.status_code)
return responseapp.py
from flask import request
from productlense_crawl import report_crawl
@app.after_request
def productlense_crawlers(response):
report_crawl(request.path, request.headers.get("User-Agent", ""), response.status_code)
return responsemain.py
from fastapi import Request
from productlense_crawl import report_crawl
@app.middleware("http")
async def productlense_crawlers(request: Request, call_next):
response = await call_next(request)
report_crawl(request.url.path, request.headers.get("user-agent", ""), response.status_code)
return responseRuby on Rails and Rack
A Rack middleware, so it works in Rails, Sinatra and any other Rack app:
require "json"
require "net/http"
class ProductLenseCrawlers # Rack middleware: reports AI crawlers after your app answers
ENDPOINT = URI("https://api.productlense.app/api/crawl")
AI_BOTS = /ChatGPT-User|Claude-User|Perplexity-User|MistralAI-User|DuckAssistBot|Meta-ExternalFetcher|Gemini-Deep-Research|cohere-ai|OAI-SearchBot|Claude-SearchBot|PerplexityBot|Googlebot|Bingbot|Applebot|DuckDuckBot|YouBot|Bravebot|GPTBot|ClaudeBot|anthropic-ai|Claude-Web|CCBot|Google-Extended|GoogleOther|Bytespider|Meta-ExternalAgent|FacebookBot|Amazonbot|cohere-training-data-crawler|Diffbot|AI2Bot|ImagesiftBot|Omgilibot|PanguBot|Timpibot/i
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
report(env, status)
[status, headers, body]
end
private
def report(env, status)
token = ENV["PL_BOT_TOKEN"]
ua = env["HTTP_USER_AGENT"].to_s
return if token.to_s.empty? || !AI_BOTS.match?(ua[0, 1024])
payload = { path: env["PATH_INFO"].to_s.empty? ? "/" : env["PATH_INFO"], ua: ua, status: status.to_i }.to_json
Thread.new do
Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, use_ssl: true, open_timeout: 2, read_timeout: 2) do |http|
http.post(ENDPOINT.path, payload, "Authorization" => "Bearer #{token}", "Content-Type" => "application/json")
end
rescue StandardError
nil # never let ProductLense affect your site
end
rescue StandardError
nil
end
endIn Rails, load it and add it to the middleware stack in config/application.rb:
require_relative "../lib/productlense_crawlers"
module YourApp
class Application < Rails::Application
config.middleware.use ProductLenseCrawlers
end
endIn any other Rack app, add use ProductLenseCrawlers to config.ru.
PHP (WordPress, Laravel)
wp-content/mu-plugins/productlense-crawlers.php
<?php
/**
* Plugin Name: ProductLense AI crawlers
* Description: Reports AI crawler visits to ProductLense.
*/
add_action('shutdown', function () {
$token = defined('PL_BOT_TOKEN') ? PL_BOT_TOKEN : getenv('PL_BOT_TOKEN');
$ua = isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';
if (!$token || !preg_match('/ChatGPT-User|Claude-User|Perplexity-User|MistralAI-User|DuckAssistBot|Meta-ExternalFetcher|Gemini-Deep-Research|cohere-ai|OAI-SearchBot|Claude-SearchBot|PerplexityBot|Googlebot|Bingbot|Applebot|DuckDuckBot|YouBot|Bravebot|GPTBot|ClaudeBot|anthropic-ai|Claude-Web|CCBot|Google-Extended|GoogleOther|Bytespider|Meta-ExternalAgent|FacebookBot|Amazonbot|cohere-training-data-crawler|Diffbot|AI2Bot|ImagesiftBot|Omgilibot|PanguBot|Timpibot/i', substr($ua, 0, 1024))) {
return;
}
$path = parse_url(isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/', PHP_URL_PATH);
$status = http_response_code();
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request(); // the crawler gets its page before the report is sent
}
wp_remote_post('https://api.productlense.app/api/crawl', array(
'timeout' => 2,
'blocking' => false,
'headers' => array('Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'),
'body' => wp_json_encode(array('path' => $path ? $path : '/', 'ua' => $ua, 'status' => $status ? $status : 200)),
));
});app/Http/Middleware/ProductLenseCrawlers.php
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\Response;
class ProductLenseCrawlers
{
private const AI_BOTS = '/ChatGPT-User|Claude-User|Perplexity-User|MistralAI-User|DuckAssistBot|Meta-ExternalFetcher|Gemini-Deep-Research|cohere-ai|OAI-SearchBot|Claude-SearchBot|PerplexityBot|Googlebot|Bingbot|Applebot|DuckDuckBot|YouBot|Bravebot|GPTBot|ClaudeBot|anthropic-ai|Claude-Web|CCBot|Google-Extended|GoogleOther|Bytespider|Meta-ExternalAgent|FacebookBot|Amazonbot|cohere-training-data-crawler|Diffbot|AI2Bot|ImagesiftBot|Omgilibot|PanguBot|Timpibot/i';
public function handle(Request $request, Closure $next): Response
{
return $next($request);
}
// Laravel calls this after the response has been sent.
public function terminate(Request $request, Response $response): void
{
$token = config('services.productlense.bot_token');
$ua = (string) $request->userAgent();
if (! $token || ! preg_match(self::AI_BOTS, substr($ua, 0, 1024))) {
return;
}
try {
Http::timeout(2)->withToken($token)->post('https://api.productlense.app/api/crawl', [
'path' => '/'.ltrim($request->path(), '/'),
'ua' => $ua,
'status' => $response->getStatusCode(),
]);
} catch (\Throwable) {
// never let ProductLense affect your site
}
}
}WordPress: create the mu-plugins folder inside wp-content if it is not there; files in it load automatically, with nothing to activate. Put the token in wp-config.php, above the "That's all, stop editing!" line: define('PL_BOT_TOKEN', 'plbot_…');. Page caches that answer without running PHP (WP Rocket, LiteSpeed Cache, Cloudflare APO, Varnish) also skip this code, so crawler visits to cached pages are missed. If you use one, add the report at the cache instead: Nginx or a Cloudflare Worker.
Laravel: add 'productlense' => ['bot_token' => env('PL_BOT_TOKEN')] to config/services.php, then register the middleware globally: on Laravel 11 and later, $middleware->append(\App\Http\Middleware\ProductLenseCrawlers::class); inside withMiddleware in bootstrap/app.php; on Laravel 10, add it to $middleware in app/Http/Kernel.php.
Netlify
An Edge Function runs in front of every page, static or not:
import type { Config, Context } from '@netlify/edge-functions';
const AI_BOTS = /ChatGPT-User|Claude-User|Perplexity-User|MistralAI-User|DuckAssistBot|Meta-ExternalFetcher|Gemini-Deep-Research|cohere-ai|OAI-SearchBot|Claude-SearchBot|PerplexityBot|Googlebot|Bingbot|Applebot|DuckDuckBot|YouBot|Bravebot|GPTBot|ClaudeBot|anthropic-ai|Claude-Web|CCBot|Google-Extended|GoogleOther|Bytespider|Meta-ExternalAgent|FacebookBot|Amazonbot|cohere-training-data-crawler|Diffbot|AI2Bot|ImagesiftBot|Omgilibot|PanguBot|Timpibot/i;
export default async (request: Request, context: Context) => {
const response = await context.next();
const ua = request.headers.get('user-agent') ?? '';
const token = Netlify.env.get('PL_BOT_TOKEN');
if (token && AI_BOTS.test(ua.slice(0, 1024))) {
const report = fetch('https://api.productlense.app/api/crawl', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: new URL(request.url).pathname, ua, status: response.status }),
}).catch(() => {});
context.waitUntil?.(report); // keeps the report alive after the response
}
return response;
};
export const config: Config = {
path: '/*',
excludedPath: ['/*.css', '/*.js', '/*.png', '/*.jpg', '/*.svg', '/*.webp', '/*.woff2', '/*.ico'],
};Nginx
If Nginx serves your site, or sits in front of your app, it can report crawlers on its own, for any language behind it:
The map goes in the http block, and both location blocks in your server block:
map $http_user_agent $pl_ai_bot {
default 0;
"~*(ChatGPT-User|Claude-User|Perplexity-User|MistralAI-User|DuckAssistBot|Meta-ExternalFetcher|Gemini-Deep-Research|cohere-ai|OAI-SearchBot|Claude-SearchBot|PerplexityBot|Googlebot|Bingbot|Applebot|DuckDuckBot|YouBot|Bravebot|GPTBot|ClaudeBot|anthropic-ai|Claude-Web|CCBot|Google-Extended|GoogleOther|Bytespider|Meta-ExternalAgent|FacebookBot|Amazonbot|cohere-training-data-crawler|Diffbot|AI2Bot|ImagesiftBot|Omgilibot|PanguBot|Timpibot)" 1;
}
location / {
mirror /_pl_crawl;
mirror_request_body off;
# ...your existing configuration
}
location = /_pl_crawl {
internal;
if ($pl_ai_bot = 0) { return 204; }
proxy_method POST;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Authorization "Bearer plbot_YOUR_TOKEN";
proxy_set_header X-PL-Path $request_uri;
proxy_set_header User-Agent $http_user_agent;
proxy_connect_timeout 1s;
proxy_read_timeout 2s;
proxy_ssl_server_name on;
proxy_pass https://api.productlense.app/api/crawl;
}Replace plbot_YOUR_TOKEN with your token, then run nginx -t and reload. Nginx cannot read environment variables here, so keep this file out of git.
Site builders and static hosts
Webflow, Framer, Wix, Squarespace, Shopify, Ghost(Pro), Carrd, GitHub Pages and similar hosts do not let you run code on their servers, so they cannot report crawlers themselves. The way to count crawlers on them is to put Cloudflare's free plan in front of your domain and add a small Worker:
- Add your domain to Cloudflare and switch its DNS to Cloudflare. Some builders do not support a proxied domain, so check your builder's help pages first.
- Make sure the DNS record for your site is Proxied (orange cloud).
- In Cloudflare, go to Workers & Pages → Create → Worker, paste the code below and deploy.
- In the Worker's Settings → Variables and Secrets, add
PL_BOT_TOKENas a Secret. - In Settings → Domains & Routes, add a route for
yourdomain.com/*(andwww.yourdomain.com/*if you use it).
const AI_BOTS = /ChatGPT-User|Claude-User|Perplexity-User|MistralAI-User|DuckAssistBot|Meta-ExternalFetcher|Gemini-Deep-Research|cohere-ai|OAI-SearchBot|Claude-SearchBot|PerplexityBot|Googlebot|Bingbot|Applebot|DuckDuckBot|YouBot|Bravebot|GPTBot|ClaudeBot|anthropic-ai|Claude-Web|CCBot|Google-Extended|GoogleOther|Bytespider|Meta-ExternalAgent|FacebookBot|Amazonbot|cohere-training-data-crawler|Diffbot|AI2Bot|ImagesiftBot|Omgilibot|PanguBot|Timpibot/i;
export default {
async fetch(request, env, ctx) {
const response = await fetch(request); // your site, unchanged
const ua = request.headers.get('user-agent') || '';
if (env.PL_BOT_TOKEN && AI_BOTS.test(ua.slice(0, 1024))) {
ctx.waitUntil(
fetch('https://api.productlense.app/api/crawl', {
method: 'POST',
headers: { Authorization: `Bearer ${env.PL_BOT_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: new URL(request.url).pathname, ua, status: response.status }),
}).catch(() => {}),
);
}
return response;
},
};The Worker passes every request through to your site as before. Cloudflare's free plan includes 100,000 Worker requests a day.
Where to put the token
| Where your site runs | Where PL_BOT_TOKEN goes |
|---|---|
| Vercel | Project → Settings → Environment Variables, then redeploy |
| Netlify | Site → Site configuration → Environment variables, then redeploy |
| Cloudflare Workers or Pages | Settings → Variables and Secrets, type Secret; or npx wrangler secret put PL_BOT_TOKEN (Workers) or npx wrangler pages secret put PL_BOT_TOKEN (Pages) |
| Render, Railway, Heroku, Fly.io | The service's environment variables or config vars (fly secrets set PL_BOT_TOKEN=… on Fly.io) |
| Docker or your own server | An environment variable for the process, from your .env file, docker run -e, or a systemd Environment= line |
| WordPress | define('PL_BOT_TOKEN', 'plbot_…'); in wp-config.php |
| Nginx | In the config file itself, as in the example |
Restart or redeploy after adding it: every option above sends nothing while the token is missing.
Step 3: Check it works
- Request one of your pages as a crawler, from any terminal:
Shell curl -A "ChatGPT-User/1.0" https://yourdomain.com/ - Open the Overview in the ProductLense dashboard and scroll to the crawler card at the bottom. Within a minute the hit appears there under AI answers.
If nothing shows up after a few minutes:
| What you see | What to do |
|---|---|
| Nothing at all | Check PL_BOT_TOKEN is set where the site actually runs, then redeploy or restart. Every snippet stays silent without it. |
| Still nothing | Test the token on its own (this adds one test hit): curl -X POST https://api.productlense.app/api/crawl -H "Authorization: Bearer $PL_BOT_TOKEN" -H "Content-Type: application/json" -d '{"path":"/test","ua":"GPTBot/1.2"}'. {"accepted":1,...} means the token works and the problem is in your server code; 401 means the token is wrong or was rotated. |
"dropped": 1 | ProductLense did not recognise the user agent. Use one from the table at the top. |
| Hits on some pages only | A cache or CDN answered the others without running your code. Report at the cache: Nginx or a Cloudflare Worker. |
What is sent
For each crawler request: the path (never the query string), the crawler's user agent and, where your server knows it, the HTTP status. Never cookies, other headers, or visitor IP addresses. Requests from people are never sent at all.
The request
To build your own integration, POST https://api.productlense.app/api/crawl with Authorization: Bearer plbot_… and a JSON body:
| Field | Required | Meaning |
|---|---|---|
path | no | The path served, or a full http(s) URL. Query and fragment are dropped; empty means /. At most 512 bytes are kept. |
ua | yes | The crawler's user agent, unchanged |
ts | no | RFC 3339 time of the request. Times more than a day old or a minute ahead are replaced by the time of receipt. |
status | no | The HTTP status your server answered with |
Send one hit, {"hits": [...]} or a JSON array of up to 50 hits; the body is limited to 16 KB. Without a body, the path is read from the X-PL-Path header and the user agent from User-Agent, as in the Nginx example.
| Status | Meaning |
|---|---|
202 | {"accepted": n, "dropped": n}. Hits from browsers and unknown agents, bad paths and hits over the allowance are dropped, not refused, so never retry them. |
401 | Missing, malformed or replaced token |
429 | Too many requests from one server address; wait for Retry-After |
503 | ProductLense is briefly busy; retry after Retry-After seconds, or skip |
Check the user agent before you send, as every example above does, so people's visits never leave your server. The pattern only keeps human traffic away from ProductLense; an out-of-date pattern costs nothing but a missed crawler.