Skip to content
Console
Browse documentation
Guide

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 empty value). It is always returned, and it does not count against limit. The channel dimension has no missing row: every session is classified, with unclassified as the last resort.
  • Rows are ranked by page views, then sessions, then value.
  • At most limit values are returned. When more existed, truncated is true and other is one further row: the distinct union of every value the limit left out. Its values says 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 google matches google.com, www.google.co.uk and news.google.de with no list of country domains. A host matches by domain when it equals the listed domain or ends with a dot and the domain, so t.co matches t.co and not xt.co.
  • That is deliberately loose, and wrong in two known ways: a brand's non-search property is classified with the brand (mail.google.com is organic_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.