# 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.

Canonical page: https://anectico.com/docs/investigate/web-analytics/


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](/docs/investigate/product-analytics): `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](#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](#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](#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](/docs/agents/proof-pages) and
[Result viewer](/docs/agents/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](#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:

```typescript
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](/docs/reference/javascript-sdk#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](#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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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](#channel-rules), 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](/docs/reference/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](/docs/manage/save-and-export-data) | Accepted: the visitors of the totals, a row, the other row or a period export like any other population. |
| [Saved insights](/docs/investigate/saved-insights) and [dashboard widgets](/docs/investigate/service-health-and-dashboards) | The recipe is saved and shown as its own result. |
| [Scheduled reports](/docs/investigate/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](/docs/investigate/product-analytics#remeasure-an-explicit-original-result) | Accepted through MCP, the CLI and REST. |
| [Metric watches](/docs/investigate/metric-watches) | Refused with `UNSUPPORTED_RESULT_SELECTOR`: its figures are not one number to evaluate. |
| [Live screens](/docs/investigate/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](/docs/reference/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

```bash
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:

```json
{
  "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:

```bash
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](/docs/investigate/heatmaps).
- **No revenue here.** Web analytics reads page views, not money. Measure revenue with
  [revenue analytics](/docs/investigate/revenue) and conversion with
  [funnels and trends](/docs/investigate/product-analytics).
- **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](/docs/investigate/customer-experience).
