Measure web analytics
Ask your agent to measure visitors, sessions, page views, bounces, visible time and scroll depth from the browser SDK's page tracking, by page, entry page, exit page, referrer, channel or campaign, page the people behind every count, and open the result as a proof page.
On this page
Web analytics answers where your visitors come from, which pages they land on and leave from, and whether they bounce. It reads the page views and page leaves your site's browser SDK records and reports visitors, sessions, page views, bounces, visible time and scroll depth — for the whole window, over time, or by page, entry page, exit page, referrer, channel or campaign parameter. Every visitors count leads to the people behind it.
It is one kind of the typed product query: web. Like
every other kind it is measured once, frozen under an execution key, and read again by result ID.
Your agent runs the measurement through MCP or the CLI. You open the stored result as a read-only
proof page.
Ask your agent
"Where did last week's sessions come from, by channel? Measure it and send me the proof link."
"Which ten pages got the most views in production last week, and how many visitors bounced?"
| Job | MCP tool or action | CLI command |
|---|---|---|
| Check which web dimensions, channels and bounds the server accepts | get_product_query_capabilities |
anectico analytics capabilities |
| Measure web analytics | query_product_analytics |
anectico analytics query |
| Read a stored web result again | get_analytics_result |
anectico analytics result get |
| Page the people behind a count | list_analytics_participants |
anectico analytics result participants |
These are MCP read actions. Your agent finds them with list_read_actions and runs them with
execute_read_action. Reading any web result needs agents:content:read in addition to
analytics:read, analytics:query and persons:read (see Permissions); over MCP
the key also needs mcp:read.
What your agent gets back
The result comes back under web, with state, closed, totals, rows, other,
truncated, buckets, leaves and, for the channel dimension, channel_rules_version. The same
seven measures are reported for the totals, each row and each period (see
What every measure means). Every visitors count and every
bounce.visitors declares a selection_id that pages the people behind it. A trimmed example is
in Run it and read the result.
Over MCP, the result carries a link to the proof page in its links, in the entry titled
"Analytics result" (the address is its href). Every tool in the table that reads this stored
result carries the same link. The CLI prints the result and no link.
Open the proof
Open the link from your agent. It opens the stored result in the read-only result viewer, with the project named in the page header. See Proof pages and Result viewer.
The page never measures again. It shows what was stored, when it was measured (in UTC) and when the stored result expires. Its one action is Copy link. To change the question, ask your agent to measure again with a new definition and a new execution key.
You see:
- The headline is the page-view count, with tiles for Visitors, Sessions, Bounce rate and Avg visible time per view. Visitors are the resolved people, with unresolved identities shown beside them as +N unresolved. The bounce rate is computed on the page from bounced sessions over sessions, with both numbers on the tile, and shows a dash when there were no sessions. The average visible time states how many views were measured and how many reported no time.
- For a series, a chart of page views, sessions or visitors per period, with every period also listed in a table below it. A period that is not finished or not fully covered is drawn hollow with a dashed edge and labelled in words in the table; a period that could not be measured has no bar and is never drawn as zero.
- For a dimension, a table that lists each value with its visitors, sessions, page views, bounce rate and average visible time, and — for pages, entry pages and exit pages — the share of measured views that scrolled to 25%, 50%, 75% and 100%. The Missing row, the Other row and a Truncated note are shown plainly, and the Total is the last row. For Pages, the page states that rows do not add up to the total and why. For Channels, the page shows the rule-set version and links to the channel rules.
- A result that is still measuring, has insufficient coverage or is unavailable says so above the
numbers; an unavailable result shows no numbers at all. A window with no page views shows an
empty state that explains page tracking is opt-in. If the person who opens the link lacks the
event-content permission, the page says so and names
agents:content:read.
What it measures, and from which events
Web analytics reads exactly two events from the browser SDK, and nothing else:
| Event | What it records |
|---|---|
$pageview |
A visitor arrived on a page: its declared route template, the referrer's origin, and the campaign parameters you chose to capture. |
$pageleave |
A page view ended: how long the page was visible ($time_on_page_ms) and how deep the visitor scrolled ($scroll_depth_percent). |
The page of either event is its $pathname: the declared route template such as /orders/:id,
never a URL, a query string or a title. A $pathname that is not a valid route template counts as
no page. Optional properties are $referrer_origin (the origin of the referring page),
$utm_source, $utm_medium, $utm_campaign, $utm_term and $utm_content, and $service_name.
Nothing is enriched. There is no country, region, city, device, browser or operating-system dimension, because none of that is captured.
Turn on page tracking
The SDK records these events only when your application opts in to page tracking, and only after the visitor has agreed to product analytics. Until you do, a project has no page views and a web measurement returns an empty result. Add the tracker once, with the route templates your site uses:
const pages = events.trackPageViews({
routes: ['/', '/pricing', '/orders/:id'],
serviceName: 'storefront',
campaigns: ['utm_source', 'utm_medium', 'utm_campaign'],
});
Referrers are recorded as an origin only, and campaign values are recorded only for the parameters
you list in campaigns. See Opt-in SPA page tracking
for routing modes, the privacy rules and the $pageleave properties.
Two things follow for what you can measure. A visitor who has not agreed to product analytics is never counted. And a page view with no campaign or referrer simply has none: it is missing or direct, not an error.
To check that tracking works, deploy the tracker, visit one of your pages, and ask your agent to
measure web analytics for the last hour with web.dimension set to page. The page you visited
should appear as a row with at least one page view. A result in the state IMMATURE or
INSUFFICIENT_EVIDENCE still discloses the counts observed so far (see
Result states). If the result is empty, check that the visitor agreed to product
analytics and that the route is one you declared.
Define a measurement
A web definition is the common definition (version, window, timezone, optional environment)
plus one web object:
{
"version": 2,
"timezone": "UTC",
"window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-09-08T00:00:00Z"}},
"web": {"dimension": "none"}
}
| Field | Meaning |
|---|---|
web.dimension |
none (the default; totals only), page, entry_page, exit_page, referrer, channel, utm_source, utm_medium, utm_campaign, utm_term or utm_content. |
web.interval |
Empty, day, week (weeks begin on Monday) or month: the totals as a time series on your timezone's calendar, clipped to the window. Accepted with dimension none only. |
web.limit |
How many dimension values to return, 1 to 100; empty or 0 means 20. It must be empty or 0 for dimension none. |
web.service_name |
Read only the page views and leaves whose $service_name equals it (1 to 128 bytes). |
Person-property filters, audiences (cohorts) and environment restrict which people are
measured and apply as usual. Web analytics is person-unit only: the account unit is refused with
QUERY_UNIT_UNSUPPORTED.
Examples
Totals for a week:
{
"version": 2,
"timezone": "UTC",
"window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-09-08T00:00:00Z"}},
"web": {"dimension": "none"}
}
Totals by day, on the Europe/Dublin calendar, for one service:
{
"version": 2,
"timezone": "Europe/Dublin",
"window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-09-15T00:00:00Z"}},
"web": {"dimension": "none", "interval": "day", "service_name": "storefront"}
}
Where sessions come from, by channel:
{
"version": 2,
"timezone": "UTC",
"window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-09-08T00:00:00Z"}},
"web": {"dimension": "channel"}
}
The ten most viewed pages, in production only:
{
"version": 2,
"timezone": "UTC",
"environment": "production",
"window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-09-08T00:00:00Z"}},
"web": {"dimension": "page", "limit": 10}
}
What every measure means
The same seven measures are reported for the totals, for each row, for the other remainder and for each period of a series.
| Measure | Definition |
|---|---|
visitors |
The distinct people who owned a page view of the set: people (resolved people) and unresolved (identities that never resolved to a person). The two are never added together. selection_id pages the resolved people. |
sessions |
The distinct sessions with a page view in the set. |
page_views |
The set's page views. |
page_views_without_session |
The page views of the set that carried no session ID. |
bounce |
sessions: the bounced sessions; of_sessions: the set's sessions, the denominator; and visitors: the people who owned a bounced session. The server computes no rate: divide sessions by of_sessions yourself. With no sessions there is no rate, not 0%. |
visible_time |
total_ms: the visible milliseconds of every measured page leave of the set's views; views: how many views reported a measured leave; views_without_leave: the views that reported none. An average is total_ms divided by views. A view with no leave reports no time and is never counted as zero. |
scroll_depth |
Over the same measured views: how many reached at least 25%, 50%, 75% and 100% of the page (reached_25 to reached_100), and views_without_leave. A view's depth is the deepest scroll any of its leaves reported. |
Every uint64 count is a decimal string in JSON. A response also carries leaves, which accounts for
every page leave in the window: leaves = attributed + unattributed + invalid.
Visible time and scroll depth
A $pageleave is measured when its $time_on_page_ms is a number from 0 to 604,800,000 (seven
days) and its $scroll_depth_percent a number from 0 to 100. Any other leave is invalid and
contributes neither time nor depth. A measured leave belongs to the latest page view of the same
visitor, session and page that happened strictly earlier; with none, it is unattributed. One
view can own several leaves, because a tab that is hidden and shown again starts a new timing period:
its visible time is the sum of them and its scroll depth the maximum.
Sessions
A session is one visitor's page views that share a session ID, inside the window you measured. Its entry is its first page view in the window (ties broken by arrival), its exit its last, and it is a bounce when exactly one of its page views is in the window.
Because nothing outside the window is read, a session that began before the window, or that carries on after it, is measured by its in-window page views only. The same visit can be a bounce in a window that holds one of its views and not a bounce in a window that holds two. Choose windows that begin and end where you want sessions to be cut; the day, week and month series are cut the same way inside one measurement.
A page view with no session ID belongs to no session. It counts toward visitors and page views, is
reported in page_views_without_session, and takes no part in sessions, bounces, or any dimension
that attributes a whole session.
Dimensions and first touch
| Dimension | A row is |
|---|---|
page |
One page. Each page view counts for its own page. |
entry_page |
One page, counted for the sessions whose first in-window page view was on it. |
exit_page |
One page, counted for the sessions whose last in-window page view was on it. |
referrer |
One referrer origin, for the sessions whose entry page view carried it. |
utm_source, utm_medium, utm_campaign, utm_term, utm_content |
One campaign value, for the sessions whose entry page view carried it. |
channel |
One channel, for the sessions whose entry page view is classified into it. |
Every dimension except page attributes a whole session to one value, and the row's page views
are every in-window page view of those sessions. Referrer, campaign and channel are first touch:
the entry page view decides, and a later page view in the same session never changes it. Each
session therefore counts in exactly one row, and the rows' sessions add up to the total.
Why page rows do not add up
A page row counts every session that viewed the page, and every person who viewed it. A session of
three pages is in three rows, and so is a person who viewed three pages. The page views of the
rows add up to the total; the sessions, visitors and bounces do not. A bounce is counted on a page
row only when that page was the session's one view.
Missing, other and truncated
- A value is a non-empty string of at most 256 bytes. Anything else is missing, and missing is
its own explicit row (
"missing": true, with an emptyvalue). It is always returned, and it does not count againstlimit. Thechanneldimension has no missing row: every session is classified, withunclassifiedas the last resort. - Rows are ranked by page views, then sessions, then value.
- At most
limitvalues are returned. When more existed,truncatedistrueandotheris one further row: the distinct union of every value the limit left out. Itsvaluessays how many values it stands for, and its visitors and sessions are distinct over that union, so they are not the sum of the omitted rows.
Channel rules
channel is Anectico's classification of each session's entry page view. It is a product
decision, written as an ordered rule table; the first rule that matches wins. The result names
the table that classified it in channel_rules_version. This is web-channels-v1; any change to a
list or to the order is a new version.
The classification reads three properties of the entry page view: the campaign medium
($utm_medium) and source ($utm_source), compared in lowercase, and the referrer's host, taken
from $referrer_origin.
| Order | Channel | A session is classified here when |
|---|---|---|
| 1 | paid_social |
the medium is paidsocial, paid_social, paid-social, social_paid or social-paid; or the medium is a paid medium (rule 2) and the source is a social source or the referrer is a social host. |
| 2 | paid |
the medium is cpc, ppc, cpm, cpv, cpa, cpp, paid, paidsearch, paid_search, paid-search, display, banner, retargeting or remarketing. |
| 3 | email |
the medium is email, e-mail, e_mail or newsletter; or the source is one of those four. |
| 4 | organic_social |
the medium is social, social-network, social_network, social-media, social_media, sm, organic_social or organic-social; or the source is a social source; or the referrer is a social host. |
| 5 | organic_search |
the medium is organic; or the referrer is a search host. |
| 6 | referral |
the session has any other referrer with a host; or the medium is referral. |
| 7 | direct |
there is no referrer and none of the five campaign values. |
| 8 | unclassified |
none of the above, for example a campaign value with no referrer and no medium a rule knows. |
The lists the rules use:
| List | Members |
|---|---|
| Social sources | facebook, instagram, twitter, x, linkedin, tiktok, youtube, reddit, pinterest, snapchat, threads, mastodon, bluesky, whatsapp, telegram, vk, weibo |
| Social hosts, by label | facebook, instagram, twitter, linkedin, tiktok, youtube, reddit, pinterest, snapchat, threads, vk, weibo, tumblr, quora, bsky |
| Social hosts, by domain | x.com, t.co, lnkd.in, fb.com, fb.me, youtu.be, redd.it, pin.it, mastodon.social, news.ycombinator.com |
| Search hosts, by label | google, bing, yahoo, duckduckgo, yandex, baidu, ecosia, startpage, qwant, naver, seznam, sogou, kagi |
| Search hosts, by domain | search.brave.com |
A value that is not a non-empty string of at most 256 bytes counts as absent.
What the rules cannot tell apart
Read the channel table as a convenient grouping, not a verdict:
- Hosts are matched without a public-suffix list. A host matches by label when any label
other than the last is a listed brand label, so
googlematchesgoogle.com,www.google.co.ukandnews.google.dewith no list of country domains. A host matches by domain when it equals the listed domain or ends with a dot and the domain, sot.comatchest.coand notxt.co. - That is deliberately loose, and wrong in two known ways: a brand's non-search property is
classified with the brand (
mail.google.comisorganic_search), and so is a third party's subdomain named after one. - The SDK reports only the referrer's origin, and nothing about your own site's host. A
same-site referrer, such as a full page load inside your site that starts a new session,
cannot be told from an external one, so it counts as
referral.
Result states
Missing data is never a measured zero. The whole result, and each period of a series, has a state
and a closed flag (the end of the span against the frozen observation boundary):
| State | Meaning |
|---|---|
PRODUCT_WEB_STATE_MEASURED |
The span has ended and platform coverage is sufficient. |
PRODUCT_WEB_STATE_IMMATURE |
The span has not ended. The counts observed so far are disclosed and may still grow. |
PRODUCT_WEB_STATE_INSUFFICIENT_EVIDENCE |
The span has ended but platform coverage is not sufficient. The observed counts are disclosed and may undercount. |
PRODUCT_WEB_STATE_UNAVAILABLE |
Platform coverage is unavailable. Nothing is disclosed: no totals, rows, periods, leaves or selections. |
MEASURED is the only state that means complete; the three others never read as a measured zero.
A series reports a state for each period: a period that ended before the observation boundary
can be measured while a later one is still immature.
Permissions
Page routes, referrers and campaign values are customer-supplied content. Reading any web result
therefore needs the event-content permission agents:content:read in addition to analytics:read,
analytics:query and persons:read — at execution, and on every later read of the result, its
people and its exports. Without it the query is refused with 403 and a message naming what is
missing. See permissions.
What is refused
A refused definition returns 400 InvalidArgument with a message that begins with a stable code.
| Code | Refused |
|---|---|
QUERY_WEB_INVALID |
A breakdown, a comparison window, a top-level event filter, a filter_group, an account filter; an unknown dimension or interval; an interval together with a dimension; a limit without a dimension; a service_name that is blank or longer than 128 bytes. |
QUERY_WEB_LIMIT |
A limit above 100, or an interval that would make more than 100 periods in the window. |
QUERY_UNIT_UNSUPPORTED |
The account unit. |
Row predicates are refused because one would remove some page views of a session and silently change
its entry, its exit and whether it bounced. To narrow by a page-view property, use service_name or
read the dimension you want.
Where a web result is accepted
| Feature | Web analytics |
|---|---|
| Result export | Accepted: the visitors of the totals, a row, the other row or a period export like any other population. |
| Saved insights and dashboard widgets | The recipe is saved and shown as its own result. |
| Scheduled reports | Accepted: identified and anonymous visitors, sessions, page views and the bounce rate as figures, and the rows or the periods as a table. |
| Remeasurement | Accepted through MCP, the CLI and REST. |
| Metric watches | Refused with UNSUPPORTED_RESULT_SELECTOR: its figures are not one number to evaluate. |
| Live screens | Refused with UNSUPPORTED_RESULT_SELECTOR. |
There is no per-visitor drill-down for web analytics: analytics result contribution refuses a
web selection, so only the roster of people is available.
Limits
| Limit | Value |
|---|---|
Dimension values returned (web.limit) |
1 to 100; 20 when omitted |
| Periods in one series | 100 |
| Service name | 128 bytes |
| One dimension value | 256 bytes; longer is missing |
| Visible time of one leave | 0 to 604,800,000 ms |
The measurement limits shared by every kind (measurements in flight, time to run, result lifetime)
are in Limits. anectico analytics capabilities reports the web kind
with its web_dimensions, web_channels, web_channel_rules_version, its intervals and the
max_web_rows, default_web_rows, max_web_buckets and max_web_participant_rows bounds.
Run it and read the result
anectico --project PROJECT_UUID analytics query --file web.json --execution-key EXECUTION_UUID
MCP's query_product_analytics takes the same definition, and REST is POST /api/v1/analytics/query
with project_id, execution_key and definition. The response carries the result under web,
and only when status is PRODUCT_RESULT_STATUS_READY. Read a result again with anectico analytics result get RESULT_UUID, MCP's get_analytics_result or GET /api/v1/analytics/results/{result_id}.
A trimmed result for the channel dimension:
{
"status": "PRODUCT_RESULT_STATUS_READY",
"web": {
"state": "PRODUCT_WEB_STATE_MEASURED",
"closed": true,
"channel_rules_version": "web-channels-v1",
"totals": {
"visitors": {"people": "3", "unresolved": "0", "selection_id": "SELECTION_UUID"},
"sessions": "4",
"page_views": "7",
"page_views_without_session": "0",
"bounce": {"sessions": "2", "of_sessions": "4", "visitors": {"people": "2", "unresolved": "0", "selection_id": "SELECTION_UUID"}},
"visible_time": {"total_ms": "8000", "views": "1", "views_without_leave": "6"},
"scroll_depth": {"views": "1", "views_without_leave": "6", "reached_25": "1", "reached_50": "1", "reached_75": "1", "reached_100": "0"}
},
"rows": [
{"value": "organic_search", "missing": false, "measures": {"sessions": "1", "page_views": "3"}},
{"value": "paid", "missing": false, "measures": {"sessions": "1", "page_views": "2"}},
{"value": "direct", "missing": false, "measures": {"sessions": "1", "page_views": "1"}},
{"value": "email", "missing": false, "measures": {"sessions": "1", "page_views": "1"}}
],
"other": null,
"truncated": false,
"buckets": [],
"leaves": {"leaves": "2", "attributed": "2", "unattributed": "0", "invalid": "0"}
}
}
(Each row's measures carries every measure above; only two are shown here.) In this example the
average visible time is 8,000 ms over the one view that reported a leave, and six views reported
none; the bounce rate is 2 of 4 sessions, 50%.
Page the people behind a count
Every visitors and every bounce.visitors declares a selection_id. Page the resolved people
behind it with the same call as any other kind:
anectico --project PROJECT_UUID analytics result participants RESULT_UUID \
--selection-id SELECTION_UUID --limit 50
MCP's list_analytics_participants and GET /api/v1/analytics/results/{result_id}/participants
take the same selection_id, limit and cursor; continue with next_cursor and keep the result,
selection and limit unchanged. A selection lists resolved people only. Unresolved identities are
counted beside them in unresolved and are not listed as people. Each selection can also be exported
or turned into a derived audience like any other resolved-people population.
What web analytics does not do
- No geography, device or browser. There is no country, region, city, device type, browser or operating-system dimension: that information is not captured.
- No cross-session attribution. Referrer, campaign and channel are first touch within one session. There is no multi-touch, last-touch or cross-session attribution model, and a visitor's later session is attributed on its own.
- No click positions. Web analytics counts page views and page leaves. For where visitors click on a page, which elements they click and how far they scroll it, use heatmaps.
- No revenue here. Web analytics reads page views, not money. Measure revenue with revenue analytics and conversion with funnels and trends.
- Only what was captured. A visitor who declined product analytics is not measured, and a page
that is not one of your declared routes is recorded by the SDK as the literal
/[unmapped].
To see what your site's browser traffic and performance look like for real users (page views by route, Web Vitals and click friction), use Customer experience.