Analytics
Traffic
Google Analytics sessions grouped by where they came from, with AI counted first, and the pages where attention is not converting.
What you see
Traffic reads every
session your Google Analytics 4 property recorded for the project, not
only the AI-referred slice: total sessions for the window, with people,
page views, conversions, and conversion value stepped down beneath it
(api/routers/integrations.py#ga4_traffic).
Beside the hero, a smaller "Of that, AI-referred" figure and its own share
of the window's sessions: the same AI referrer classification
AI traffic uses,
summed with no page or channel cap, so no AI source is left out of it,
current and prior window alike
(api/services/integrations/google/ga4_traffic_metrics.py#sum_ai_sessions,
#ai_share). A window with none says so as a real zero, not a blank.
Conversions and conversion value are sums of the same rows
AI traffic reads for the same
window: GA4's own count and revenue stand on every row except where one of
your named conversion events fired, or GA4's own purchase and sign_up
events if you have named none, in which case that event's own count and
value replace GA4's figure on that row
(api/services/integrations/google/ga4_attribution.py#_load_project_context,
#_DEFAULT_CONVERSION_EVENTS). Each shows a dash and a short note instead
of a bare zero when the window's total comes to nothing either way.
Three change rules run side by side here. The hero's sessions figure, the
AI-referred figure beside it, and the Change column in all three tables
lower down share one chip, which renders nothing at all when the prior
window's figure for that row is zero, rather than a change measured up
from zero (frontend/src/lib/components/DeltaChip.svelte#delta). The four
tiles stepped down from the hero are handed a plain subtraction of the two
windows instead, and a prior figure of zero is a real zero to that
subtraction (frontend/src/lib/components/Stat.svelte#delta). So on a
window with nothing before it, each of those tiles that has a figure to
show reads as a rise of that whole figure, printed as a bare count where it
would usually carry a percentage. The hero showing no chip while the tiles
beneath it show a rise is the tell that the prior window is empty. The
movers list follows a third rule: its own delta is written out as plain
text and always prints, including the zero-prior case the chip suppresses.
These figures do not all rest on one basis. The import asks GA4 for five
metrics at a single grain and stores a row per property, page, source,
medium, country, and day
(api/services/integrations/google/ga4_attribution.py#import_ga4_daily_for_project,
#_upsert_metrics). Page views and conversions are counts of events, and
conversion value is the revenue those events carried: an event happened on
one page, on one day, from one source, in one country, so it lands in
exactly one row, and adding the rows up returns the window's own total.
Page views are therefore GA4's own figure for the same days wherever they
appear on this screen. Conversions and conversion value are exact in the
same way, but they are not always GA4's own number: on any row where one of
your named events fired, the stored pair is that event's count and value
rather than the property's, and the totals on this screen add both kinds
together (#_upsert_metrics), as described further up this page.
Sessions are only as exact as the grouping they are read under, because a
visit is recorded against every page it touched. Any figure that does not
break down by page therefore adds a visit up once per page: the hero's
sessions, the AI-referred figure beside it, the Channels tab and the
channel mix built from it, the Countries tab, and the daily trend all come
out at roughly GA4's own count for the same days multiplied by the number
of pages an average visit covers. The Pages tab does break down by page, so
its session count is the visits that included that page, which is what GA4
reports for it (api/routers/integrations.py#ga4_traffic).
People inflate on a second axis and no grouping on this screen removes it. The day is part of the stored grain, so a person is counted once for every day they were active as well as once for every page they saw: one visitor returning daily for four weeks reads as twenty-eight people, on a one-page site where the sessions figure is exact. Read People as a ceiling on your audience rather than a count of it.
The two bases meet wherever this screen divides one by the other. A page's
own conversion rate in "Pages that underconvert" puts exact conversions
over exact page sessions, but the site average it is measured against
divides by the inflated total, so that benchmark sits below your real site
rate and the list is a conservative one: fewer pages clear the bar, and a
smaller shortfall is estimated for each, than the true average would
produce. The list itself is the top eight either way
(api/routers/integrations.py#ga4_traffic,
api/services/integrations/google/ga4_traffic_metrics.py#below_avg_converters).
The Channels tab's Rate column divides exact conversions by inflated
channel sessions the same way, so read it as a floor rather than as the
rate GA4 would give you.
Where sessions come from
Every session falls into exactly one of seven channel buckets, checked in
this order: AI first (a source on the AI referrer list
AI traffic
classifies), then Paid (a medium such as cpc, cpm, or display),
Organic (medium exactly organic), Social (a social medium, or a source
such as facebook.com, x.com, or reddit.com), Referral (medium
exactly referral), Direct (source (direct) or direct, or a medium
that is empty, none, or (none)), then Other for anything left over. Other is the
unconditional fallback, so nothing falls outside those seven
(api/services/integrations/google/ga4_traffic_metrics.py#channel_group).
AI is checked first on purpose: an AI referral whose GA4 medium happens to
read referral, organic, or nothing at all would otherwise land in that
bucket instead, splitting the AI slice across several rows. Checking it
first keeps this bucket, the hero's AI-referred figure, and the Channels
tab's AI badge all agreeing about which sessions count as AI
(#channel_group).
The bar and list show the seven buckets in a fixed order, AI, Organic,
Direct, Referral, Social, Paid, Other, skipping any with no sessions this
window (#channel_mix,
frontend/src/routes/(app)/performance/traffic/+page.svelte#channelMix). They are
built from the same up to 100 source/medium rows that back the Channels
tab beneath, ranked by session count, not the window's full total
(#ga4_traffic); the hero's AI-referred figure has no such cap, so on a
site with more than 100 distinct channels in a window, this chart's shares,
AI's included, can diverge from the hero's own figure above it.
Pages that underconvert
Two lists, switched by a toggle, both drawn from the same up to 100 pages
that back the Pages tab below: each keeps only pages with at least 10
sessions in the window, and each is capped at 8 rows
(api/routers/integrations.py#TRAFFIC_OPP_FLOOR, #TRAFFIC_OPP_TOP_N).
"Below average" compares each page's own conversion rate against the whole
window's rate (total conversions over total sessions, unbounded by that
100-page pool), keeps only pages under that benchmark, and
ranks by an estimated missed-conversions figure: sessions times how far
the rate sits below average
(api/services/integrations/google/ga4_traffic_metrics.py#below_avg_converters).
Until a conversion has been recorded in the window, that benchmark is
zero, so the tab explains that instead of listing anything.
"No conversions" has no benchmark to wait for: it lists pages with at
least 10 sessions and exactly zero counted conversions, ranked by
sessions
(api/services/integrations/google/ga4_traffic_metrics.py#zero_conversion_pages).
A page lands here only when no conversion fired on it at all: not one of
GA4's own key events, not one of your named conversion events, and not the
purchase/sign_up default that applies when you have named none.
Every row arrives carrying GA4's own key-event count, and the replacement
only lands on the rows where one of the counted events actually fired, so
a page where anything converted holds a count either way
(api/services/integrations/google/ga4_attribution.py#_upsert_metrics).
This list is therefore not simply every well-trafficked page before you set
anything up: a property already recording GA4 key events excludes the pages
those events fired on, named conversion events or not.
Movers
Movers compares sessions in the window against the window immediately
before it (see below for exactly how the two compare), across the union of
two capped lists: this window's top 100 channels or pages, and the prior
window's own top 100, each capped separately. A row can therefore appear
here without appearing in the tabs above, which show only this window's
100. Kept only where the larger of the two sides reaches 5 sessions, then
split into gainers and losers, each capped at 8 rows
(api/routers/integrations.py#TRAFFIC_MOVER_FLOOR, #TRAFFIC_MOVER_TOP_N,
api/services/integrations/google/ga4_traffic_metrics.py#compute_session_movers).
Anything inside one window's 100 and outside the other's is compared against 0 for the side it is missing from, whether or not it truly was zero. The two cutoffs are set independently of each other, so that 0 can stand in for a figure larger than the one it is being compared with, and then the direction is wrong rather than just the size: a page with 15 sessions in a busy prior window, too few for that window's 100, and 5 in a quieter current one, enough for this window's 100, publishes as a gainer of 5 against a real fall of 10. Every row this can have happened to shows a zero on one side, so they are the ones to read twice: the gainers whose gain equals their whole session count, and the losers showing no sessions in this window. A row carrying a real figure on both sides has an exact change. A row whose sessions did not change appears in neither list: only a real increase counts as a gainer and only a real decrease as a loser, so this is a diff, not every row with enough volume.
The trend, and the breakdowns beneath it
The daily trend draws total sessions with AI-referred sessions overlaid on
the same scale, zero-filled for every calendar day in the window whether
or not that day has actually been imported yet. It renders whenever
?days= is 4 or higher, which every preset (7, 28, or 90) clears easily.
Only a hand-set ?days= that lands on 1, 2, or 3 suppresses it: 0, a
negative, or anything that is not a number falls back to the default 28 and
the trend draws
(frontend/src/routes/(app)/performance/traffic/+page.svelte#hasTrend,
api/services/integrations/google/ga4_traffic_metrics.py#build_daily_series,
frontend/src/routes/(app)/performance/traffic/+page.server.js#windowDays).
Beneath it, three tabs, Channels, Pages, and Countries, each list up to
100 rows ranked by sessions, with a change chip against that row's own
prior-window sessions where that prior figure is not zero (a row with
nothing to compare against shows no chip, the same rule the hero's own
sessions figure uses). Channels and Pages both add
users, page views,
conversions, and value; Channels also carries a conversion-rate column and
the same AI badge the channel mix's first bucket represents. Countries
drops page views but keeps the rest (#ga4_traffic). The row count under
the tabs is how many rows came back, not a hint that more exist beyond it.
The window, and how the two periods compare
Left alone, this screen opens on the last 28 days. You can switch it to 7,
28, or 90 days, or set ?days= yourself higher still; 180 is the ceiling
this screen enforces regardless of what the URL asks for
(frontend/src/routes/(app)/performance/traffic/+page.server.js#windowDays).
Every change figure here, from the hero down to each row in Channels,
Pages, and Countries, compares the selected window against the window
immediately before it, on the same terms
Search performance
uses: the current window runs from window_days days ago through today,
both ends inclusive, which is window_days + 1 days; the prior window
covers exactly window_days days, ending the moment the current one
starts, with no gap and no overlap. At the default 28-day setting that is
29 days of current data against 28 days of prior data
(api/routers/integrations.py#ga4_traffic).
Before you see data
Traffic needs the same Google Analytics 4 connection and property mapping
as AI Traffic; both screens check the same mapped properties for this
project (a project can have more than one), so mapping one starts the
import that feeds both. With nothing connected, the screen prompts you to
connect; connected but unmapped, it prompts you to map a property; mapped
but this window still reads zero sessions, whether because the first
import has not landed yet, the window itself is genuinely empty, or the
request to fetch it failed, it says a day's wait is normal rather than
showing an empty chart
(frontend/src/routes/(app)/performance/+layout.server.js#ga4Connected,
frontend/src/routes/(app)/performance/traffic/+page.server.js#ga4Mapped,
frontend/src/routes/(app)/performance/traffic/+page.svelte#ga4HasData). See
Troubleshooting: Performance is empty
for exactly when the recurring import runs.
Related
- AI traffic: the AI-referred slice this screen's hero figure summarises, broken out by engine
- Search performance: the equivalent breakdown for Google Search Console, using the same current-versus-prior window arithmetic
- Troubleshooting: what an empty or stale-looking screen usually means
Last verified 2026-08-10