I build GSC Wizard, an SEO analytics tool. A while back I added an MCP (Model Context Protocol) server so people could ask an AI assistant — ChatGPT, Claude, whatever — questions about their own search and analytics data and get real numbers back instead of hallucinations.
Wiring up Google Search Console was the easy part. Google Analytics 4 was where it got interesting. GA4's Data API has quirks that don't matter much when a human clicks a dashboard, but bite hard the moment an LLM is generating the requests. This post is about those quirks and how I dealt with them.
There are six GA4 tools in the server:
| Tool | What it answers |
|---|---|
list_ga4_properties |
Which GA4 properties can I read? |
get_ga4_overview |
Sessions, users, engagement — with period-over-period comparison |
query_ga4_report |
One breakdown (channel / source / page / country / device / event) |
get_ga4_ecommerce |
Revenue, transactions, top products |
get_ga4_key_events |
Conversions, segmented by any key event |
get_ga4_llm_traffic |
How much traffic is AI assistants sending you |
I'll spend most of the post on the last one, because it's the most novel, then cover the plumbing that makes all six reliable.
Detecting traffic from AI assistants
This is the tool people react to. "How much of my traffic comes from ChatGPT, Perplexity, Gemini?" is a question every marketer suddenly has, and GA4 doesn't answer it out of the box — you have to know what to look for.
AI assistants show up in your GA4 sessionSource dimension in two shapes:
-
The web domain — someone clicks a link inside ChatGPT, so you see
chatgpt.com,perplexity.ai,gemini.google.com. -
A UTM alias — a tagged link arrives as
utm_source=chatgptorutm_source=openai.
So the source of truth is a hand-maintained list of assistants, each with its domains and its aliases:
// llm-sources.ts — one entry per assistant (18 of them)
{
name: 'ChatGPT',
domains: ['chatgpt.com', 'chat.openai.com'],
aliases: ['chatgpt', 'openai'],
},
{
name: 'Perplexity',
domains: ['perplexity.ai', 'pplx.ai'],
aliases: ['perplexity'],
},
// Copilot, Gemini, Claude, DeepSeek, Grok, Mistral, Meta AI,
// Poe, You.com, Phind, Kimi, Qwen, Genspark, Felo, iAsk, Andi...
That list drives two functions whose matching semantics have to stay byte-for-byte identical, or your filter and your labels disagree:
-
buildLLMSourceRegex()builds an RE2-compatible pattern for the Data API's server-sideFULL_REGEXPfilter. Domains match exactly or as any subdomain —(.*\.)?(chatgpt\.com|perplexity\.ai|...)— and aliases match as exact values. -
classifyLLMSource(value)runs client-side to map a returnedsessionSourceback to a display name, sochat.openai.comandchatgpt.comboth roll up into "ChatGPT."
Push the filtering server-side and you only pay for the rows you want. Keep the classifier in lockstep and rows that slip through but don't classify land in an "Other LLM" bucket instead of vanishing.
The honest caveat, baked into the tool
Here's the part I made sure the tool description states outright: Google's AI Overviews and AI Mode carry no distinct referrer. That traffic is indistinguishable from plain google / organic. So this tool measures assistants that send a click with a referrer — it is not, and cannot be, a total "AI influence" number. I'd rather the model tells the user that than quietly implies precision that doesn't exist.
Grouping without losing the rate
One subtle bug I designed around: an assistant can appear under several sessionSource values (chatgpt.com and chat.openai.com). If you fetch engagementRate per row and then average, you get nonsense — you can't average two rates. So the tool fetches engagedSessions and sessions as raw counts and recomputes the rate after grouping:
engagementRate = sum(engagedSessions) / sum(sessions)
The tool also fetches an unfiltered sessions total in the same batch, purely as the denominator for "LLM traffic is X% of all sessions." That share-of-traffic number is the one people actually screenshot.
The plumbing that makes it survive an LLM
An LLM driving your API is a fundamentally different client from a dashboard. It sends malformed inputs, it fires requests in bursts, and it has no idea which of your three Google accounts owns the property. Four things had to be solid.
1. Tolerant date parsing
Models pass dates as null, the string "null", empty strings, 2026/07/01, or full ISO timestamps with a time component. If your schema rejects any of that, the tool just fails and the model apologizes to the user. So the date schema