DEV Community

Cover image for utm_source=chatgpt.com: how to see which pages ChatGPT visitors land on (GA4 API and server logs)
LovedByAI
LovedByAI

Posted on

utm_source=chatgpt.com: how to see which pages ChatGPT visitors land on (GA4 API and server logs)

If you've spotted utm_source=chatgpt.com in your analytics or your access logs, you've found the people ChatGPT sends you. The useful next question is where they land, and on most small business sites we measured, the answer isn't mainly the homepage.

We build LovedByAI, a generative engine optimization platform for small businesses built around WordPress, and its plugin logs every ChatGPT visit with the page it lands on. Across 23,740 ChatGPT visits to 323 small business websites between 14 September and 13 October 2026 (sites with at least 10 ChatGPT visits, each site counted once), the homepage took 42% of ChatGPT's visitors on the average service business site and 23% on the average online store, where product pages took 37%. The full study is ChatGPT referral traffic: where visitors land.

This post covers how to get the same list for your own site, or for every client site you look after: from the GA4 interface, from the GA4 Data API, and from raw access logs, with a script we ran before publishing.

What does utm_source=chatgpt.com mean?

utm_source=chatgpt.com is a query parameter ChatGPT appends to the links in its answers. When a person clicks one, the request for your page carries it, and Google Analytics 4 records the visit with the session source chatgpt.com. The browser usually sends https://chatgpt.com/ as the referrer too.

Three things that look similar but aren't the same:

  • A ChatGPT-User request is not a visitor. That's OpenAI's fetcher reading a page during a conversation. It shows up in logs with a bot user agent and no tag.
  • Paid clicks can carry the tag too. A link tagged utm_medium=paid or cpc is an ad click, not an answer. The study left those out, along with two other ad-click parameters we saw in our data (oppref and olref), and the script below does the same.
  • Some organic ChatGPT clicks now carry nothing. Since 8 October 2026, part of ChatGPT's traffic arrives with no tag and no referrer, so GA4 files it under Direct and a log can't tell it from a typed URL. Everything below is a sample, not a total.

How to find ChatGPT landing pages in the GA4 interface

  1. Open Reports, then Engagement, then Landing page.
  2. Add filter: dimension Session source, exactly matches, chatgpt.com.
  3. Set the range to the last 90 days and sort by Sessions.

GA4's default channel group now also has an AI Assistant channel (Google's channel definitions), and in our own property ChatGPT's tagged sessions show up there with the medium ai-assistant. We still filter on Session source. Google's rule puts a session in that channel when its medium is ai-assistant, which GA4 sets when the referrer matches its list of assistants, and in our property some chatgpt.com sessions were filed under Organic Search instead. The source is what the tag sets directly.

How to pull ChatGPT landing pages with the GA4 Data API

The same report as a runReport request. POST it to https://analyticsdata.googleapis.com/v1beta/properties/<PROPERTY_ID>:runReport (the "Try this method" panel on that page works, as does any Google client library):

{
  "dateRanges": [{ "startDate": "90daysAgo", "endDate": "yesterday" }],
  "dimensions": [{ "name": "landingPage" }],
  "metrics": [{ "name": "sessions" }],
  "dimensionFilter": {
    "filter": {
      "fieldName": "sessionSource",
      "stringFilter": { "matchType": "EXACT", "value": "chatgpt.com" }
    }
  },
  "orderBys": [{ "metric": { "metricName": "sessions" }, "desc": true }],
  "limit": 1000
}
Enter fullscreen mode Exit fullscreen mode

We checked this request against the Data API on our own GA4 property before publishing. Two things to expect in the response: landingPage is the path without the query string (use landingPagePlusQueryString if you want it), and some rows come back as (not set), sessions GA4 couldn't attach a page to. Save the response as a .json file; the script reads it as is, in either the REST shape (dimensionValues) or the client library's (dimension_values).

How to find ChatGPT visits in server logs

A ChatGPT visit in a combined-format access log is a request whose query string carries utm_source=chatgpt.com, or whose referrer is chatgpt.com. The second page of the same visit has your own site as its referrer, so it doesn't match, which means each match is a landing. These demo lines (invented, from the documentation IP ranges) show what counts and what doesn't:

203.0.113.10 - - [06/Oct/2026:08:01:12 +0000] "GET /?utm_source=chatgpt.com HTTP/1.1" 200 18233 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1"
192.0.2.80 - - [06/Oct/2026:12:45:00 +0000] "GET /contact/ HTTP/1.1" 200 9001 "https://chatgpt.com/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
192.0.2.91 - - [06/Oct/2026:13:20:33 +0000] "GET /?utm_source=chatgpt.com&utm_medium=cpc HTTP/1.1" 200 18233 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
203.0.113.10 - - [06/Oct/2026:08:02:40 +0000] "GET /services/burst-pipe-repair/ HTTP/1.1" 200 15002 "https://demo-plumber.example/?utm_source=chatgpt.com" "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1"
192.0.2.150 - - [06/Oct/2026:14:40:02 +0000] "GET /services/burst-pipe-repair/ HTTP/1.1" 200 15002 "-" "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot"
Enter fullscreen mode Exit fullscreen mode

In order: a tagged landing on the homepage (counted); a landing on /contact/ with only the referrer (counted); a paid click (skipped); the visitor from the first line moving to a second page, with your own site as referrer (skipped); ChatGPT-User fetching a page (skipped, it's a bot).

Two caveats for production logs. If a CDN serves cached HTML, those hits never reach your origin log, so read the CDN's logs instead. And a reload logs twice, where GA4 counts one session.

How to split ChatGPT landing pages by page type with a Python script

chatgpt_landings.py takes one or more sources, one per website: an access log, or a saved GA4 runReport response. For each it prints the split by page type and the top pages, then compares the average across sites with the pooled total. Standard library only, Python 3.8 or later.

#!/usr/bin/env python3
"""Where do ChatGPT's visitors land? One split per website.

    python3 chatgpt_landings.py SOURCE [SOURCE ...]

Each SOURCE is one website, either:
  - an access log in the combined format (the nginx and Apache default), or
  - a saved GA4 Data API runReport response (.json) with landingPage as the
    first dimension and sessions as the first metric.

Prints each site's split by page type and its top pages, then the average
across sites (every site counts once) next to the pooled split.
Standard library only, Python 3.8 or later.
"""
import json
import re
import sys
from collections import Counter
from urllib.parse import parse_qs, unquote, urlsplit

LINE = re.compile(
    r'(?P<ip>\S+) \S+ \S+ \[[^\]]+\] "(?P<method>\S+) (?P<target>\S+)[^"]*" '
    r'(?P<status>\d{3}) \S+ "(?P<referer>[^"]*)" "(?P<ua>[^"]*)"'
)
CHATGPT_HOSTS = {"chatgpt.com", "chat.openai.com"}
# Clicks on paid placements: the study left these out.
PAID = re.compile(r"[?&]utm_medium=(paid|cpc|ads?)(&|$)|[?&](oppref|olref)=", re.I)
NOT_A_PERSON = re.compile(r"bot|crawler|spider|ChatGPT-User", re.I)
ASSET = re.compile(r"\.(css|js|map|png|jpe?g|gif|webp|svg|ico|woff2?)$", re.I)

# Rough URL rules, checked in order. The study typed pages with an AI model;
# edit these to match your own site's URL structure.
RULES = [
    ("homepage", r"^/([a-z]{2}(-[a-z]{2})?/)?$"),
    ("product", r"^/(product|products|item)/"),
    ("category", r"^/(product-category|shop|collections?)(/|$)"),
    ("blog post", r"^/(blog|news|articles?|guides?)/.+|^/\d{4}/\d{2}/"),
    ("service", r"^/(services?|treatments?|practice-areas?)/.+"),
    ("location", r"^/(locations?|areas?|service-areas?)/.+"),
    ("about", r"^/(about|about-us|team|our-story)/?$"),
    ("contact/booking", r"^/(contact|contact-us|book|booking|appointments?|quote|get-a-quote)/?$"),
    ("pricing", r"^/(pricing|prices|rates|plans)/?$"),
]
RULES = [(name, re.compile(rx, re.I)) for name, rx in RULES]


def page_type(path):
    for name, rx in RULES:
        if rx.search(path):
            return name
    return "other"


def from_chatgpt(target, referer):
    """A person who clicked a link in a ChatGPT answer (tag or referrer)."""
    if "chatgpt.com" in parse_qs(urlsplit(target).query).get("utm_source", []):
        return True
    return (urlsplit(referer).hostname or "") in CHATGPT_HOSTS


def read_log(path):
    pages, dead = Counter(), Counter()
    with open(path, encoding="utf-8", errors="replace") as log:
        for raw in log:
            m = LINE.match(raw)
            if not m or m["method"] != "GET" or NOT_A_PERSON.search(m["ua"]):
                continue
            page = unquote(urlsplit(m["target"]).path).lower() or "/"
            if ASSET.search(page) or PAID.search(m["target"]):
                continue
            if from_chatgpt(m["target"], m["referer"]):
                pages[page] += 1
                if m["status"] == "404":
                    dead[page] += 1
    return pages, dead, 0


def read_ga4(path):
    """A runReport response, REST (camelCase) or client-library (snake_case)."""
    report = json.load(open(path, encoding="utf-8"))
    pages, not_set = Counter(), 0
    for row in report.get("rows", []):
        dims = row.get("dimensionValues") or row.get("dimension_values")
        mets = row.get("metricValues") or row.get("metric_values")
        page, sessions = dims[0]["value"], int(mets[0]["value"])
        if page == "(not set)":
            not_set += sessions
            continue
        pages[page.lower()] += sessions
    return pages, Counter(), not_set


def split(pages):
    by_type = Counter()
    for page, n in pages.items():
        by_type[page_type(page)] += n
    total = sum(by_type.values())
    return {t: n / total for t, n in by_type.items()}, total


def main(sources):
    shares, pooled = [], Counter()
    for src in sources:
        pages, dead, not_set = read_ga4(src) if src.endswith(".json") else read_log(src)
        if not pages:
            print(f"\n{src}: no ChatGPT visits found")
            continue
        site, total = split(pages)
        shares.append(site)
        for page, n in pages.items():
            pooled[page_type(page)] += n
        print(f"\n{src}: {total} ChatGPT landings")
        for t, s in sorted(site.items(), key=lambda kv: -kv[1]):
            print(f"  {100 * s:5.1f}%  {t}")
        top = pages.most_common(3)
        print(f"  top 3 pages take {100 * sum(n for _, n in top) / total:.0f}%: "
              + ", ".join(p for p, _ in top))
        if not_set:
            print(f"  ({not_set} sessions with landing page '(not set)' left out)")
        if dead:
            print(f"  landed on a 404: {sum(dead.values())} ({', '.join(dead)})")

    if len(shares) > 1:
        types = sorted({t for s in shares for t in s}, key=lambda t: -sum(s.get(t, 0) for s in shares))
        pooled_total = sum(pooled.values())
        print(f"\nAcross {len(shares)} sites    average of sites   pooled visits")
        for t in types:
            avg = sum(s.get(t, 0) for s in shares) / len(shares)
            print(f"  {t:17s}  {100 * avg:14.1f}%  {100 * pooled[t] / pooled_total:13.1f}%")


if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit(__doc__)
    main(sys.argv[1:])
Enter fullscreen mode Exit fullscreen mode

The page types come from rough URL rules at the top. Our study sorted 7,211 landing pages with an AI model instead; for one site, editing RULES to match your URL structure is quicker and good enough.

We ran it on two demo sites: an access log from an invented plumber (plumber-access.log, 12 lines) and a saved GA4 response from an invented shop (shop-ga4-runreport.json, 8 rows):

$ python3 chatgpt_landings.py demo/plumber-access.log demo/shop-ga4-runreport.json

demo/plumber-access.log: 7 ChatGPT landings
   28.6%  homepage
   28.6%  service
   14.3%  blog post
   14.3%  contact/booking
   14.3%  other
  top 3 pages take 71%: /, /services/burst-pipe-repair/, /blog/how-to-shut-off-your-water-main/
  landed on a 404: 1 (/emergency-plumber-24-7/)

demo/shop-ga4-runreport.json: 125 ChatGPT landings
   50.4%  product
   24.0%  homepage
   12.0%  category
    9.6%  blog post
    2.4%  about
    1.6%  contact/booking
  top 3 pages take 74%: /product/linen-apron/, /, /product/canvas-tote/
  (4 sessions with landing page '(not set)' left out)

Across 2 sites    average of sites   pooled visits
  homepage                     26.3%           24.2%
  product                      25.2%           47.7%
  service                      14.3%            1.5%
  blog post                    11.9%            9.8%
  contact/booking               7.9%            2.3%
  other                         7.1%            0.8%
  category                      6.0%           11.4%
  about                         1.2%            2.3%
Enter fullscreen mode Exit fullscreen mode

The plumber's 12 log lines become 7 landings: the paid click, the Google visit, the stylesheet, the second page of a visit and the ChatGPT-User fetch all drop out. The 404 is a link ChatGPT handed out to a page that doesn't exist, which is a separate problem we covered in why ChatGPT sends visitors to broken links.

Why should you average ChatGPT landing pages per site instead of pooling visits?

If you look after several client sites, the last table is the one to read. Pooled, the shop's 125 sessions swamp the plumber's 7, and product pages look like 47.7% of ChatGPT traffic. Averaged per site, they're 25.2%. Neither site looks like the pooled number.

The study hit the same thing at scale. Across the 333 sites with at least 10 ChatGPT visits between 14 September and 13 October 2026, the five biggest held 26% of all visits. Pooled, listing pages (directory entries) came out at 16.4% of ChatGPT traffic, mostly from one directory; averaged per site, 2.2%. Pooling also pushed the homepage down to 22.7%, against 32.7% when every site counts once.

Medians don't rescue it either. A typical site gets ChatGPT visitors on only three or four kinds of page, so the median share for most page types is zero and can't rank anything. Use the per-site average, plus a count of how many sites had any visit on each type.

What does a normal ChatGPT landing page split look like?

Benchmarks from LovedByAI's data, per-site averages, 14 September to 13 October 2026, sites with at least 10 ChatGPT visits. "Service business" means no ChatGPT visitor landed on a product, category or listing page; "online store" means at least one landed on a product or category page.

Page type Service business (175 sites) Online store (148 sites)
Homepage 42% 23%
Product 0% 37%
Product category 0% 13%
Service 18% 6%
Blog post 18% 11%
About 4% 2%
Location 4% 1%
Contact or booking 3% 1%
Listing 0% 2%
Pricing 1.4% 0.3%
Other 10% 4%

The spread matters as much as the split. On the median service business site, ChatGPT visitors landed on 7 different pages in the month, and the top 3 took 78% of them. On the median store it was 16 pages, with the top 3 taking 53%. And blog posts are lopsided: on 47% of the service sites no ChatGPT visitor landed on a blog post, while on 14% blog posts took half or more.

If your own split is far from these, that's not a problem in itself. It tells you which pages ChatGPT uses to introduce you, and those are the pages to look at first.

[[VIDEO: planned youtube video | How to track AI traffic in GA4: the landing page report for ChatGPT visitors, the AI Assistant channel, and what 23,740 ChatGPT visits say you'll find.]]

What can't utm_source=chatgpt.com data tell you?

  • What visitors did next. Landing pages only. Nothing here says one page type converts better than another.
  • The untagged share. Since 8 October 2026 some ChatGPT visits carry no tag and no referrer. Neither GA4 nor a log can attribute them, and we can't check whether they land on the same pages.
  • Perfect page types. The study typed pages from the URL with an AI model, and the script uses regex rules. Both will misfile some pages.
  • Every kind of site. The benchmarks come from WordPress sites running our plugin, mostly small businesses, with at least 10 ChatGPT visits in the month.

For the owner's side of this, without the code: ChatGPT traffic: why most of it lands past your homepage.

LovedByAI's plugin keeps this log on every WordPress site it runs on, which is where the 23,740 visits above come from, and the platform automates the on-page optimization across those pages and the rest of the site. If you're not on WordPress, the script and the GA4 request get you the same list by hand.

Original study and full dataset: https://www.lovedby.ai/blog/chatgpt-referral-traffic

Top comments (0)