When I pulled the last 90 days of GA4 for this site, 91% of the sessions were Direct. They were not people: Chrome from data-center locations with about zero seconds of engagement. Meanwhile ChatGPT, Copilot and Perplexity together sent 6 sessions. One glance at the default dashboard and I would have drawn two wrong conclusions.
This is a measurement playbook, not a strategy one (the strategy lives in the two GEO posts linked at the end). You will see which AI bots exist and what each vendor says they do, how to write robots.txt per bot, how to read GA4’s AI Assistant channel and back it up with a regex, how to count crawler hits from logs, and how to check that a bot is genuine. Every vendor claim below comes from the vendor’s own documentation, checked in October 2026.
1. The Problem: Your Dashboard Measures the Wrong Things
AI search breaks measurement in three places, and each one fails in a different direction. Bots that run your JavaScript inflate Direct with fake sessions. Bots that only fetch HTML never reach GA4 at all, so you never see them. And real people who click an answer inside an AI app often arrive with no referrer, so they hide in Direct too. One dashboard cannot untangle that: you need three data sources, analytics, server logs and robots.txt.
The Most Common Mistake
Reading Direct as ’people who type my URL’ or, worse, as ’AI traffic GA4 missed’. GA4 only excludes known bots, and Google says you cannot disable that exclusion or see how much was excluded. Automated Chrome from a data center is counted as a normal session. Filter bot-like sessions first, interpret afterwards.
This site’s numbers (first-party, 90 days to 14 Oct 2026)
Pulled with the GA4 Data API for this portfolio. It is one small site, not an industry statistic: read it as a worked example of how raw channels mislead, not as a benchmark.
~1,568 sessions
total, 90 days
Everything GA4 recorded, before any filtering.
~1,434 Direct (91%)
almost all automated
Mostly Chrome from data-center locations with about 0 s of engagement: automated clients that run JavaScript, not readers.
100 Organic Search
the real human channel
Classic search is still where most of this site’s human visitors come from.
6 AI Assistant
tiny sample
chatgpt.com 2, copilot.com 2, perplexity.ai 2. Plus Referral 5 and Organic Social 4.
The comparison that matters: about 17 human sessions from classic search for every one from an AI assistant, here. With numbers this small I would not read a trend into it, but I would not trust a report that shows AI traffic as zero either, or one that shows Direct as 91% of my audience.
2. Minimum Concepts Before Touching Code
Six ideas cover almost everything you need. With these straight, the robots.txt, the GA4 channel and the log script read like plain English.
Three bot jobs
the taxonomy that matters
Vendors split bots by purpose: training or model improvement (GPTBot, ClaudeBot, meta-externalagent), search indexing (OAI-SearchBot, Claude-SearchBot, PerplexityBot) and user-triggered fetches (ChatGPT-User, Claude-User, Perplexity-User). Each has its own robots.txt token, so you can treat them differently.
Token vs crawler
control without traffic
Google-Extended and Applebot-Extended are robots.txt tokens, not crawlers. Google says Google-Extended has no separate user agent string and crawling uses existing Google user agents, so it never shows up in your logs.
Referrer, UTM or nothing
three ways a click arrives
A visit from an AI app carries an HTTP referrer, a UTM tag, both or neither. Neither means Direct. Vendor data varies a lot: Clickport’s April 2026 sample had 35.7% of AI-classified sessions with a source tag but no recorded referrer, and it cites another vendor (Loamly, Feb 2026) reporting 70.6% without a referrer.
GA4 AI Assistant channel
built in since May 2026
Google’s default channel group has an AI Assistant channel: the medium must exactly match ai-assistant, applied when the referrer matches Google’s list. AI Overviews and AI Mode are excluded and count as Organic Search.
User agents are claims
spoofable by anyone
A User-Agent string proves nothing. Genuine crawlers can be checked against IP ranges the vendor publishes (OpenAI, Anthropic, Perplexity, Google, Apple) or via reverse DNS (Google, Apple).
Logs see what tags cannot
the other half
GA4 only sees clients that run your tag. A client that fetches HTML without executing JavaScript never fires it, so only server logs show GPTBot, ClaudeBot or ChatGPT-User.
The mental model to keep
Logs answer ’who fetched me and for what’. GA4 answers ’who arrived and did something’. robots.txt answers ’who may use me and how’. A bot hit is never a visit, and a missing referrer is never proof of zero AI influence.
3. Step 1 — Inventory the Bots (From Vendor Docs)
Start from what vendors publish about themselves, not from blog posts: names change and some guides still list agents the vendor no longer documents (Anthropic’s current help article lists ClaudeBot, Claude-User and Claude-SearchBot and does not mention anthropic-ai). Everything below was checked against each vendor’s own page in October 2026.
OAI-SearchBot
OpenAI · search
Surfaces sites in ChatGPT’s search features. Opt out and you are not shown in ChatGPT search answers, though you can still appear as navigational links. OpenAI says robots.txt changes take about 24 hours to apply to search.
GPTBot
OpenAI · training
Crawls content that may be used to train OpenAI’s foundation models. Disallowing it signals your content should not be used for training.
ChatGPT-User
OpenAI · user actions
Used for certain user actions in ChatGPT and Custom GPTs, not for automatic crawling. OpenAI notes that because a user initiates it, robots.txt rules may not apply, and that it is not used to decide Search inclusion.
Claude-SearchBot
Anthropic · search
Analyzes content to improve the relevance and accuracy of search results. Disabling it prevents your content from being indexed for search optimization.
ClaudeBot
Anthropic · training
Gathers content that may be used for model training. Blocking it signals future content should be excluded from training datasets. Anthropic says it honors robots.txt and supports the non-standard Crawl-delay.
Claude-User
Anthropic · user queries
Fetches pages when a Claude user asks a question. Disabling it prevents retrieval of your content in response to a user query.
PerplexityBot
Perplexity · search
Surfaces and links sites in Perplexity results. Perplexity says it is not used to crawl for AI foundation models and recommends allowing it in robots.txt.
Perplexity-User
Perplexity · user actions
Visits a page when a user’s question needs it. Perplexity says it generally ignores robots.txt because a user requested the fetch.
Google-Extended
Google · token, no crawler
A robots.txt token that governs whether content Google crawls may be used for training future Gemini models and for grounding in Gemini Apps and Vertex AI. Google says it does not affect inclusion in Search or ranking. AI Overviews and AI Mode follow the regular Search controls (Googlebot, nosnippet, max-snippet).
Applebot / Applebot-Extended
Apple · search + token
Applebot crawls for Spotlight, Siri and Safari features and its data may also help train Apple foundation models. Applebot-Extended is a secondary token that only controls that training use: it does not crawl and disallowing it does not remove you from search.
meta-webindexer, -externalagent, -externalfetcher
Meta · three jobs
meta-webindexer improves Meta AI search quality; meta-externalagent crawls for training or indexing; meta-externalfetcher fetches links at a user’s request and may bypass robots.txt.
Google user-triggered fetchers
Google · AI-related
Google lists AI-related fetchers such as Google-Agent, Google-GeminiNotebook, Google-Pinpoint and Google-Read-Aloud, and says user-triggered fetchers generally ignore robots.txt because a user asked for the fetch.
Reading the list
Search bots feed answers, training bots feed models, user-triggered fetchers are a person asking a question. A ChatGPT-User or Claude-User hit is the closest log signal to ’an assistant is using my page right now’, but it is still not a human visit and not a citation click.
4. Step 2 — robots.txt, One Group per Job
robots.txt is where the taxonomy becomes policy: allow the bots that retrieve your content for answers, decide training separately, and keep one catch-all group. Several User-agent lines can sit above one set of rules, as Google’s own docs show.
public/robots.txt — one group per job (Pages Router serves it as a static file)
# 1) Search and retrieval bots: they feed answers, allow them User-agent: OAI-SearchBot User-agent: Claude-SearchBot User-agent: PerplexityBot User-agent: meta-webindexer User-agent: Applebot Allow: / Disallow: /admin Disallow: /api/ # 2) User-triggered fetchers: a person asked about one of your pages. # Some vendors say these may ignore robots.txt anyway. User-agent: ChatGPT-User User-agent: Claude-User User-agent: Perplexity-User User-agent: meta-externalfetcher Allow: / Disallow: /admin Disallow: /api/ # 3) Training / model improvement: YOUR decision (example: opt out) User-agent: GPTBot User-agent: ClaudeBot User-agent: Google-Extended User-agent: Applebot-Extended User-agent: meta-externalagent Disallow: / # 4) Everyone else (Googlebot, Bingbot, ...) User-agent: * Allow: / Disallow: /admin Disallow: /api/ Sitemap: https://www.diego-rodriguez.work/sitemap.xml
If you generate the file with next-sitemap, the same groups go under robotsTxtOptions.policies. One subtlety bites people: Google’s docs say a user agent matches only the first, most specific group, so a bot-specific group does not inherit the Disallow lines of the * group. That is why every group above repeats /admin and /api/.
Training bots: a neutral tradeoff
Allowing training bots means vendors may use your pages to train or improve models; none of the docs I read promises more citations in return. Blocking is the vendors’ stated opt-out from training, and OpenAI, Apple and Google each say training controls are separate from search inclusion (GPTBot vs OAI-SearchBot, Applebot-Extended, Google-Extended). It is a licensing and brand decision, not a ranking trick. This site allows everything, which fits a portfolio; a publisher monetising an archive may reasonably choose otherwise.
robots.txt is a request, not a lock
Compliant crawlers honour it. User-triggered fetchers may not (OpenAI, Perplexity, Meta and Google all say so for theirs) and a spoofed user agent ignores it entirely. If content must not be fetched, put authentication or a CDN firewall rule in front of it.
5. Step 3 — GA4: The AI Assistant Channel and a Regex Fallback
Some older guides still tell you GA4 has no AI channel and that you must build one. Google’s documentation says it does: an AI Assistant channel was added to the default channel group on May 13, 2026 (Analytics release notes). The rule is that the medium exactly matches ai-assistant, and GA4 sets that medium (and the campaign (ai-assistant)) when the referrer matches a list of AI assistants. Google names ChatGPT, Gemini, DeepSeek, Copilot and Grok as examples, publishes the full list only as a spreadsheet, and keeps AI Overviews and AI Mode in Organic Search.
Two limits matter. First, it only works when GA4 saw a recognised referrer: sessions with neither referrer nor tag still fall to Direct, and independent write-ups also report tagged-but-medium-less sessions ending in Unassigned. Second, the list is Google’s, not yours. On this property perplexity.ai already landed in AI Assistant, while one independent write-up reports Perplexity staying in Referral, so verify on your own data. A custom channel group gives you a list you control, next to the native one.
GA4 Admin → Data display → Channel groups → Create new channel group
Channel name: AI assistants (custom) Condition: Source matches regex Pattern: ^(.*\.)?(chatgpt\.com|chat\.openai\.com|perplexity\.ai|claude\.ai|gemini\.google\.com|copilot\.com|copilot\.microsoft\.com|deepseek\.com|grok\.com|you\.com)$ Order: drag it ABOVE Referral (and above Organic Search if AI sources show up there)
Why exact domains, and what is retroactive
GA4 regex is a full match and case sensitive by default, so the pattern must describe the whole Source value. Google’s own example uses broad substrings such as .*gpt.*, which can also catch unrelated sources; a list of domains is stricter. Traffic goes to the first channel whose definition it matches, so order matters, and Google says custom groups can be applied to reports retroactively (not to audiences). Standard properties allow 2 custom groups. Extend the list from your own Source report.
scripts/ga4-channels.js — channels with engagement, bot-like Direct flagged
// npm i @google-analytics/data
// GOOGLE_APPLICATION_CREDENTIALS=key.json GA_PROPERTY_ID=123456789 node scripts/ga4-channels.js
const { BetaAnalyticsDataClient } = require("@google-analytics/data");
const client = new BetaAnalyticsDataClient();
const HUMAN = ["Organic Search", "AI Assistant", "Referral", "Organic Social"];
async function main() {
const [res] = await client.runReport({
property: "properties/" + process.env.GA_PROPERTY_ID,
dateRanges: [{ startDate: "90daysAgo", endDate: "today" }],
dimensions: [{ name: "sessionDefaultChannelGroup" }, { name: "sessionSource" }],
metrics: [{ name: "sessions" }, { name: "userEngagementDuration" }],
limit: 500,
});
const by = {};
for (const row of res.rows || []) {
const [channel, source] = row.dimensionValues.map((d) => d.value);
const sessions = Number(row.metricValues[0].value);
const engaged = Number(row.metricValues[1].value); // seconds, summed
const c = (by[channel] ||= { sessions: 0, engaged: 0, sources: {} });
c.sessions += sessions;
c.engaged += engaged;
c.sources[source] = (c.sources[source] || 0) + sessions;
}
const total = Object.values(by).reduce((n, c) => n + c.sessions, 0);
const sorted = Object.entries(by).sort((a, b) => b[1].sessions - a[1].sessions);
for (const [name, c] of sorted) {
const avg = c.engaged / Math.max(c.sessions, 1);
const top = Object.entries(c.sources).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([s, n]) => s + " " + n).join(", ");
// heuristic, not a verdict: lots of sessions with ~0 s engagement
const verdict = HUMAN.includes(name) ? "human" : avg < 1 && c.sessions > 20 ? "BOT-LIKE" : "check";
console.log([name.padEnd(18), String(c.sessions).padStart(6), ((100 * c.sessions) / total).toFixed(0) + "%", avg.toFixed(1) + "s", verdict, top].join(" "));
}
}
main().catch((e) => { console.error(e.message); process.exit(1); });Run it and read the engagement column next to the session count. A channel with hundreds of sessions and under a second of engagement is not an audience; that is the number to remove before anyone presents an AI-share percentage.
6. Step 4 — Logs: Count the Bots GA4 Cannot See
Your server or CDN logs record every request, including from clients that never run JavaScript. Two cheap ways to get a bot report: parse the access log you already have, or emit one JSON line per AI-bot hit from middleware.
scripts/ai-bots-report.js — hits per bot from a combined-format log
// node scripts/ai-bots-report.js /var/log/nginx/access.log (Node 16+)
const fs = require("node:fs");
const readline = require("node:readline");
// user-agent token -> job
const BOTS = {
"OAI-SearchBot": "search", GPTBot: "training", "ChatGPT-User": "user fetch",
"Claude-SearchBot": "search", ClaudeBot: "training", "Claude-User": "user fetch",
PerplexityBot: "search", "Perplexity-User": "user fetch",
Applebot: "search (+training)", "meta-webindexer": "search",
"meta-externalagent": "training", "meta-externalfetcher": "user fetch",
};
// combined log format: ip - - [time] "METHOD path HTTP/x" status bytes "referer" "user-agent"
const LINE = /^(\S+) \S+ \S+ \[[^\]]+\] "\S+ (\S+)[^"]*" (\d{3}) \S+ "[^"]*" "([^"]*)"/;
async function main(file) {
const stats = {};
const rl = readline.createInterface({ input: fs.createReadStream(file) });
for await (const line of rl) {
const m = LINE.exec(line);
if (!m) continue;
const [, ip, path, status, ua] = m;
const bot = Object.keys(BOTS).find((n) => ua.toLowerCase().includes(n.toLowerCase()));
if (!bot) continue;
const s = (stats[bot] ||= { job: BOTS[bot], hits: 0, ok: 0, robots: 0, ips: new Set(), paths: {} });
s.hits++;
if (status[0] === "2") s.ok++;
if (path === "/robots.txt") s.robots++;
s.ips.add(ip);
s.paths[path] = (s.paths[path] || 0) + 1;
}
const top = (paths) => Object.entries(paths).sort((a, b) => b[1] - a[1])[0][0];
console.table(
Object.entries(stats)
.sort((a, b) => b[1].hits - a[1].hits)
.map(([bot, s]) => ({ bot, job: s.job, hits: s.hits, "2xx %": Math.round((100 * s.ok) / s.hits), robots: s.robots, ips: s.ips.size, top: top(s.paths) }))
);
}
main(process.argv[2]).catch((e) => { console.error(e.message); process.exit(1); });On a host without raw access logs, middleware gives you the same signal. This is Next.js 13 Pages Router syntax; Next.js 16 renamed the convention to proxy.js, so adjust if you upgrade. It runs on every matched request, so keep it cheap: it is a measurement tool, not a firewall.
middleware.js — one JSON line per AI-bot request
// middleware.js (project root, next to pages/) · Next.js 13 Pages Router
import { NextResponse } from "next/server";
const AI_BOTS =
/(OAI-SearchBot|GPTBot|ChatGPT-User|Claude-SearchBot|ClaudeBot|Claude-User|PerplexityBot|Perplexity-User|Applebot|meta-webindexer|meta-externalagent|meta-externalfetcher|Google-Agent)/i;
export function middleware(request) {
const ua = request.headers.get("user-agent") || "";
const hit = ua.match(AI_BOTS);
if (hit) {
// one JSON line per hit: grep it, or ship it with your host's log drain
const ip = request.headers.get("x-forwarded-for");
console.log(JSON.stringify({ type: "ai-bot", bot: hit[1], path: request.nextUrl.pathname, ip, t: new Date().toISOString() }));
}
return NextResponse.next();
}
// run on pages and robots.txt, skip build assets
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};What a bot hit means (and does not)
Count robots.txt fetches separately from page fetches: a bot that reads robots.txt first is evidence it follows the rules. Search-bot hits show you are indexed for retrieval, user-fetcher hits show a person asked about a page of yours, training-bot hits show nothing about visibility. None of them is a visit, a citation or a click.
7. Step 5 — Verify the Bot Is Real
A user agent string is a claim any script can make. Before a number goes into a report, check the source IP: OpenAI, Anthropic, Perplexity, Google and Apple publish IP ranges as JSON lists of CIDR prefixes, and Google and Apple document reverse DNS too (Google: reverse lookup, hostname under googlebot.com, google.com or googleusercontent.com, then forward lookup back to the same IP).
scripts/verify-bot-ip.js — published ranges plus reverse and forward DNS
// node scripts/verify-bot-ip.js 203.0.113.7 GPTBot (Node 18+: global fetch)
const { BlockList, isIP } = require("node:net");
const dns = require("node:dns").promises;
// IP range files published by each vendor: { prefixes: [{ ipv4Prefix | ipv6Prefix }] }
const RANGES = {
GPTBot: "https://openai.com/gptbot.json",
"OAI-SearchBot": "https://openai.com/searchbot.json",
"ChatGPT-User": "https://openai.com/chatgpt-user.json",
ClaudeBot: "https://claude.com/crawling/bots.json", // one list for Anthropic's bots
PerplexityBot: "https://www.perplexity.com/perplexitybot.json",
Googlebot: "https://developers.google.com/static/crawling/ipranges/common-crawlers.json",
Applebot: "https://search.developer.apple.com/applebot.json",
};
// Google and Apple also document reverse DNS (hostname suffixes)
const RDNS = {
Googlebot: [".googlebot.com", ".google.com", ".googleusercontent.com"],
Applebot: [".applebot.apple.com"],
};
async function inRanges(bot, ip) {
const { prefixes } = await (await fetch(RANGES[bot])).json();
const list = new BlockList();
for (const p of prefixes) {
const [addr, len] = (p.ipv4Prefix || p.ipv6Prefix).split("/");
list.addSubnet(addr, Number(len), p.ipv4Prefix ? "ipv4" : "ipv6");
}
return list.check(ip, isIP(ip) === 6 ? "ipv6" : "ipv4");
}
// reverse DNS -> hostname ends with a documented suffix -> forward DNS returns the same IP
async function rdnsOk(bot, ip) {
try {
for (const host of await dns.reverse(ip)) {
if (!RDNS[bot].some((s) => host.endsWith(s))) continue;
if ((await dns.lookup(host, { all: true })).some((a) => a.address === ip)) return true;
}
} catch (e) {
// no PTR record: not verified
}
return false;
}
(async () => {
const [ip, bot] = process.argv.slice(2);
if (!RANGES[bot]) throw new Error("unknown bot: " + bot);
console.log({ bot, ip, inRanges: await inRanges(bot, ip), rdnsOk: RDNS[bot] ? await rdnsOk(bot, ip) : "n/a" });
})();Cache the JSON files, they change. Anthropic warns that blocking by IP can stop its crawler from reading your robots.txt and may not give a reliable opt-out, so use ranges to verify what you already logged and robots.txt for policy. For vendors whose pages I read without an IP list (Meta, for one), treat user-agent-only counts as unverified.
8. Step 6 — Structured Data and the Vendors’ Own Reports
In the Pages Router, JSON-LD is a plain script tag with type application/ld+json inside next/head. The Next.js docs recommend a native script tag rather than next/script, and warn that JSON.stringify does not sanitize strings, so replace < with its unicode escape. Give the tag a key so it is not duplicated across renders.
components/JsonLd.js — Pages Router
// components/JsonLd.js (Pages Router: render inside next/head)
import Head from "next/head";
export default function JsonLd({ id, data }) {
return (
<Head>
<script
key={id}
type="application/ld+json"
dangerouslySetInnerHTML={{
// JSON.stringify does not sanitize: escape "<" so a value can never close the tag
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
}}
/>
</Head>
);
}
// pages/blog/my-post.js
<JsonLd
id="article-ld"
data={{
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: "Measuring AI search visibility",
datePublished: "2026-10-14",
dateModified: "2026-10-14",
author: { "@type": "Person", name: "Diego Rodriguez", url: "https://www.diego-rodriguez.work" },
}}
/>What vendors actually say about structured data
Google: you do not need new machine-readable files, AI text files or special schema.org markup to appear in AI Overviews and AI Mode, and structured data should match the visible text. Microsoft: Fabrice Canel said at SMX Munich (March 2025) that schema markup helps Bing’s LLMs understand content, as reported by Search Engine Land. The OpenAI, Anthropic and Perplexity bot pages I read say nothing about structured data. Treat JSON-LD as hygiene that removes ambiguity, not a lever you can measure in isolation.
Vendor-side reports worth opening
Bing Webmaster Tools has an AI Performance report (public preview since February 2026) with citations, cited pages per day, page-level counts and grounding queries across Copilot, AI summaries in Bing and select partners. Bing says grounding queries are a sample, and the announcement mentions no click data. Google says AI feature traffic is included in Search Console’s overall Web search totals. Neither replaces your own logs.
9. A Measurement Routine That Holds Up
Measurement decays when it is a one-off. This is the cadence I would run on any site that cares about AI visibility, in the order the data gets cheaper to read:
📅 Every week (10 minutes)
- GA4 channels: run ga4-channels.js and read engagement next to sessions, never channel names alone
- Bot log: run ai-bots-report.js on the last 7 days: hits per bot, 2xx share, robots.txt fetches, top paths
- New sources: scan the sources inside Referral and Unassigned for AI-looking domains
🗓️ Every month
- Verify: check any new bot name against the vendor’s published ranges before it enters a report
- Vendor diff: re-read each vendor’s bot page, names and purposes change
- Vendor consoles: export Bing AI Performance and compare its cited pages with the pages your logs show being fetched
🧹 Every quarter
- Regex review: update the custom channel group with domains you saw in Referral
- Policy review: does the training allow or block decision still match your business?
- Baseline: write down human sessions, AI Assistant sessions and verified AI-bot hits, and ignore raw Direct
10. Common Errors (All of Them Fixable in an Afternoon)
Almost every misleading AI-traffic number I have seen falls into one of these buckets. The left column is what trustworthy reports do; the right column is what produces a confident wrong slide.
✅ Do this
- • Filter bot-like sessions (Direct, ~0 s engagement, data-center locations) before reading any channel
- • Treat search, training and user-triggered bots as three separate policies
- • Verify bot IPs against vendor ranges before reporting hits
- • Report AI sessions as ’at least N’: referrer-less visits are invisible
- • Keep a custom channel group next to the native AI Assistant channel and compare them
- • Repeat Disallow rules inside every bot-specific robots.txt group
❌ Not this
- • Reading Direct as human, or as ’AI traffic GA4 missed’
- • Blocking Google-Extended to change AI Overviews: Google says it does not affect Search
- • Counting GPTBot or ClaudeBot hits as visibility: they are training crawls
- • Adding llms.txt or AI-only markup for Google: Google says it is not needed
- • Using robots.txt as access control for sensitive paths
- • Quoting a vendor study’s ’X% of AI traffic is Direct’ as a law: 35.7% and 70.6% came from different samples
Golden rule
Measure in three layers, logs for who fetched you, GA4 for who arrived, vendor consoles for who cited you, and never merge them into one number. Each answers a different question, and a bot hit is not a visit.
Conclusion
You now have three instruments: a bot inventory taken from vendor docs, a robots.txt that encodes one policy per job, and two reports (GA4 by channel and engagement, logs by bot) that you can verify against published IP ranges. None of them tells you ’how visible am I in AI answers’ on its own. Together they tell you what you can and cannot claim.
This week: run both scripts once, write down three numbers (human sessions, AI Assistant sessions, verified AI-bot hits) and stop reporting the raw Direct line. Then use the GEO posts below to decide what to change, and come back in a month to see whether the numbers moved.
Tutorial Cheat Sheet
Bots
- • Search / training / user fetch
- • Verify by IP range or rDNS
- • Logs count hits, not visits
GA4
- • AI Assistant channel (ai-assistant)
- • Custom regex channel above Referral
- • Filter Direct with ~0 s engagement
robots.txt
- • Allow search + user bots
- • Decide training separately
- • Repeat Disallow in each group
Sources
- OpenAI — Overview of OpenAI crawlers (OAI-SearchBot, GPTBot, ChatGPT-User)
- Anthropic — Does Anthropic crawl data from the web, and how can site owners block the crawler?
- Perplexity — PerplexityBot and Perplexity-User
- Google — Common crawlers (Googlebot, Google-Extended)
- Google — User-triggered fetchers
- Google — Verifying requests from Google crawlers and fetchers
- Google Search — AI features and your website
- Google Search — Create and submit a robots.txt file
- Apple — About Applebot (Applebot-Extended, reverse DNS, IP list)
- Meta — Web crawlers (meta-webindexer, meta-externalagent, meta-externalfetcher)
- Google Analytics Help — Default channel group (AI Assistant)
- Google Analytics Help — Custom channel groups
- Google Analytics Help — About regular expressions
- Google Analytics Help — Known bot-traffic exclusion
- Google Analytics — Release notes (AI Assistant channel, May 13 2026)
- Next.js — How to implement JSON-LD
- Next.js — proxy.js (formerly middleware.js) and matcher
- Bing Webmaster Blog — Introducing AI Performance (public preview)
- Clickport — Why ChatGPT traffic shows as Direct in GA4 (vendor data)
- Search Engine Land — Microsoft Bing/Copilot use schema for its LLMs (secondary report)



