Skip to content
Console
Browse documentation
Guide

Measure heatmaps

Ask your agent to measure where visitors click on one page, which elements they click and how far down they scroll, from the browser SDK's autocapture and page tracking, with the people behind every cell, element and depth. A heatmap is a stored result, opened as a proof page, optionally drawn over a masked copy of the page.

On this page

A heatmap answers three questions about one page: where do visitors click, which elements do they click, and how far down do they scroll. It reads the clicks, rage clicks, page views and page leaves your site's browser SDK records, counts them, and gives you the people behind every cell, every element and every depth.

It is one kind of the typed product query: heatmap. 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. A heatmap is a stored result, and you open it as a read-only proof page in the result viewer. There is no separate heatmap page.

Ask your agent

"Where do visitors click on /pricing on a phone-sized screen, over the last 7 days? Include rage clicks and send me the proof link."

"How far down do people scroll the docs home page this week?"

Job MCP tool or action CLI command
Check heatmap modes, viewport classes and bounds get_product_query_capabilities anectico analytics capabilities
Measure clicks, elements or scroll depth of one page query_product_analytics anectico analytics query
Read a stored heatmap again get_analytics_result anectico analytics result get
Page the people behind a cell, an element or a depth list_analytics_participants anectico analytics result participants
Read the project's browser capture settings get_capture_settings anectico capture-settings get
Change them, including the page snapshot update_capture_settings anectico capture-settings enable-page-snapshot, anectico capture-settings disable-page-snapshot, anectico capture-settings set-page-snapshot
List which pages have a stored page copy list_page_backdrops anectico replay page-backdrops list
Delete a stored page copy delete_page_backdrop anectico replay page-backdrops delete

The measurement tools are MCP read actions. Your agent finds them with list_read_actions and runs them with execute_read_action; the two writes are write actions. Reading any heatmap result needs agents:content:read in addition to analytics:read, analytics:query and persons:read (see Permissions). Capture settings need ingestion:pipelines:read to read and ingestion:pipelines:write to change. Listing page copies needs replay:read, reading a copy needs replay:content:read and deleting one needs replay:delete. Over MCP the key also needs mcp:read (reads) or mcp:write (writes).

What your agent gets back

The result comes back under heatmap, with state, closed and one section for the mode you asked for: clicks (totals, positions, cells, other, viewports), elements (rows, which include the missing-selector row, other, viewports) or scroll (page_views, measured_views, bands, leaves). Every cell, element row and band carries {people, unresolved, selection_id}, and the selection_id pages the people behind it. Trimmed examples are 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 heatmap 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 look at another page, mode, viewport class or window, ask your agent to measure again with a new definition and a new execution key.

You see:

  • A headline with the click count (or the page-view count for scroll depth), with tiles for the Clickers (people, with unresolved identities shown beside them as +N unresolved) and the clicks placed on the grid. A state notice above the numbers says when the result is still measuring or has insufficient coverage; an unavailable result shows no numbers at all.
  • For clicks, the grid: one square per cell that holds clicks, darker for more. When the definition names one viewport class and a page snapshot is stored, the grid is drawn over the masked copy of the page, with the capture date beneath it. Otherwise it is drawn on the abstract page, a tall rectangle that stands for the whole document, with the rulers in percent. A legend states the real least and most clicks of a cell, and the page says plainly that this is positions on the page, not a screenshot. Each cell has a tooltip with its figures, and large cells print their count, so no number depends on colour. The same cells are listed, ranked, in a table beside it. Cells can be reached with the keyboard: tab to the grid, move between cells with the arrow keys, and press Enter to open the people.
  • A note that accounts for the clicks that carried no position or an invalid one, and says to turn on capturePointer and to check the project's Browser capture settings.
  • When rage clicks are counted, a toggle that shows them as dashed outlines with a corner marker.
  • When more than one viewport class contributed, a notice that the layouts were merged, with each class and its clicks. To look at one class, ask your agent to measure again with viewport_width_bucket.
  • For elements, a ranked table: the selector in monospace, the tag, clicks, rage clicks if counted, and the clickers. The Missing row, the Other row and a Truncated note are shown plainly.
  • For scroll depth, a vertical chart of the share of measured views that reached 10% to 100% of the page, with the number of measured views as the denominator. Views with no leave are stated beside it and never drawn as depth zero. The same bands are in a table below.
  • A window with no clicks (or no page views) shows an empty state that explains what to enable — autocapture, capturePointer, page tracking — with links to the SDK reference and to the project's browser capture settings. If the person who opens the link lacks the event-content permission, the page says so and names agents:content:read.

What a heatmap is here, and what it is not

A heatmap is made of positions and elements. The measurement never reads your page: it counts events. What you get:

  • Clicks are counted in a grid: a column and a row are a share of the page's width and height, counted from the top left, never pixels of a particular layout. By default the proof page draws that grid on an abstract page, a tall rectangle that stands for the whole document, with the rulers in percent. If you turn on page snapshots, it draws the same grid over a masked copy of the page instead.
  • Elements are counted by the element that was clicked, named by the CSS path the SDK computed for it (such as button#buy) and its tag. No visible text, label or attribute of any element is ever read or returned.
  • Scroll depth is counted in ten fixed bands from the top of the page to the bottom.

No screenshot is ever taken, and nothing is loaded from your site when you open a heatmap.

What it reads, and what to turn on

A heatmap reads these events from the browser SDK and nothing else:

Mode Events read What it needs from your site
clicks, elements $autocapture events whose type is click, and, if you ask for them, $rageclick Autocapture turned on, with click among its domEvents.
clicks (positions) The click's $pointer_document_x_percent, $pointer_document_y_percent and $viewport_width_bucket capturePointer: true in autocapture. Without it a click has no position and sits in no cell.
scroll $pageview and $pageleave Page tracking. The page leave carries how deep the visitor scrolled.

A change or a submit is autocaptured under the same event name as a click, and is not a click: it is never counted. Everything is recorded only after the visitor has agreed to product analytics.

Turn them on once, with the same declared route templates for both:

const routes = ['/', '/pricing', '/orders/:id'];
events.trackPageViews({ routes, serviceName: 'storefront' });
startAutocapture(events, {
  routes,                       // the same templates as page tracking
  serviceName: 'storefront',    // so a page's views and clicks answer to the same service filter
  domEvents: ['click'],
  capturePointer: true,         // click positions: required for the click grid
});

Three things can keep a heatmap empty or without positions, and the proof page names each:

  1. Autocapture is opt-in. It starts nothing on import, and nothing is recorded until your application starts it.
  2. The project's capture settings can narrow your code. If a project's browser capture settings switch autocapture off, or switch capture pointer position off, a site that obeys the settings (remoteConfig) collects no clicks or no positions however it was written. The settings can only make a site collect less than its code asks, never more. In the Console, open Privacy (/privacy) and choose the Browser capture tab; your agent can read them with get_capture_settings or anectico capture-settings get. See the Console guide.
  3. Set serviceName on both page tracking and autocapture if you filter a heatmap by service: the filter reads each event's own $service_name, so an event sent without it is not found.

Elements and areas you block or mask send no click, or send one without text, and a heatmap shows exactly that.

To check that clicks and positions arrive, click once on a page you declared, then ask your agent for a clicks heatmap of that page over the last hour. The click must be counted in totals, and positions.positioned must count it. If your mouse click is counted in positions.without_position instead, pointer capture is off in your code or in the project's capture settings.

The page behind the grid

Positions are easier to read over the page they fall on. Anectico can keep one masked copy of each page for that purpose, and the proof page then draws the click grid over it. It is off until you turn it on.

What the copy is

A structural copy of the page as one visitor's browser rendered it: its elements, their classes and the layout rules of your stylesheets, so the layout is recognisable. It is not a screenshot and not a recording. By default it is a wireframe:

  • Every visible character is replaced by x. Words keep their length and lines keep their wrapping, and nothing can be read. The server refuses a copy that contains readable text while this is on, whatever sent it.
  • Images are left out and keep their space as empty boxes.
  • No input value is ever copied: not a text field, a text area, a selected option or an editable region. This cannot be switched off.
  • Blocked areas are empty boxes. Anything you block, and any selector you list under block, is replaced by a box of the same size with nothing inside.
  • Only layout is copied. A copy holds a fixed list of ordinary page elements, the attributes that shape layout (classes, inline styles, sizes, table spans) and style rules. It holds no element id, name, title, alternative text, placeholder, link address, data attribute or framework attribute, so text kept in those is not copied either, whether or not text is masked. Three kinds of name you choose are copied as written, with or without masking: class names, custom property names (--brand-color) and layer or container names. Avoid putting personal data in any of them. Nothing else you name is copied: style values are limited to numbers and standard CSS keywords.
  • Text in style sheets is not copied. A style rule keeps no quoted text, so text a page generates from its CSS (content: "…") is left out, with or without masking.
  • Nothing in it can run or load. Scripts, event handlers, frames, fonts, animations and every reference to another address are left out in the browser, and the server refuses a copy that contains anything outside the list, whatever sent it. The proof page shows the copy in an isolated frame that can run no script, make no request and take no focus.

One copy is kept per page (route template), viewport class and service; a newer capture replaces the older one. It is stored with your session-replay data and expires with it.

Turn it on

Two switches, and both are needed:

  1. In the project. Enable the page snapshot in the project's capture settings. In the Console, open Privacy (/privacy), choose the Browser capture tab and use the Heatmap page snapshots card (see the Console guide). Your agent can do the same with MCP's update_capture_settings, and from a terminal:

    anectico --project PROJECT_UUID capture-settings enable-page-snapshot

    capture-settings set-page-snapshot sets the whole section, update_capture_settings takes a page_snapshot object with only the fields to change, and REST takes the section as page_snapshot in PUT /api/v1/projects/{projectId}/capture-settings:

    Field Default Meaning
    enabled false The switch. While false no copy is asked for or accepted.
    mask_all_text true Replace every visible character by x.
    mask_selectors none CSS selectors whose text is always replaced. At most 50.
    block_selectors none CSS selectors replaced by an empty box. At most 50.
    inline_images false Embed images in the copy, where the browser is allowed to read them.
    sample_percent 10 How often a page that needs a copy asks for one, 1 to 100: the share of minutes in which its visitors are asked. Everyone visiting the page in the same minute gets the same answer.
    refresh_days 7 How old a copy may be, 1 to 365 days, before a new one is asked for.

    These settings are a ceiling: your site can mask and block more than they say, never less. capture-settings disable-page-snapshot is the kill switch. It takes effect at once, without a redeploy: no copy is asked for, and one that still arrives is refused.

  2. In your site. Start page snapshots with the same route templates and service name as page tracking and autocapture, and the key you use for session replay:

    import { startPageSnapshots } from '@anectico/sdk/page-snapshot';
    
    startPageSnapshots({
      endpoint: appEndpoint,
      apiKey: replayKey,            // a key holding only replay:write
      collection,                   // nothing is captured without the visitor's replay consent
      routes,                       // the same templates as page tracking
      serviceName: 'storefront',
    });

A copy is kept only for a page your project has recorded page views or clicks for in the last 30 days, under the same route template and service name. A copy is taken once the page has loaded and stopped changing, and again after a route change, but only when the project wants one for that page and viewport class. It is taken in short steps while the browser is idle, and given up if the visitor moves to another page first. A visitor's browser uploads at most one per page per visit, and most visits upload none.

See it

Ask your agent for a clicks heatmap with one viewport class (viewport_width_bucket). If a copy exists for that page and class, the proof page draws the grid over it, with a line saying that the image was captured from a visitor's browser, and when. Without a viewport class the layouts of every width are merged and no single picture is true of them, so the abstract page is drawn. The abstract page is also what you see when there is no copy yet, or when you lack permission to read replay content.

Reading the copy needs replay:content:read; listing which pages have one needs replay:read. List and delete them with anectico replay page-backdrops list and anectico replay page-backdrops delete, MCP's list_page_backdrops and delete_page_backdrop, or GET and DELETE /api/v1/replay-page-backdrops. The proof page only shows the copy; it has no button that deletes it. Deleting one returns that page to the abstract rectangle until the next capture.

What to keep in mind

  • It is one visitor's page. The copy has that visitor's page height, their signed-in state and whatever variant they were shown. Other visitors' pages may be taller or shorter, and each click is a share of its own page, so a cell may not sit exactly on the element in the copy.
  • It may be older than the clicks. The caption states the capture date. After a redesign, delete the copy or wait for the refresh.
  • It is a visitor's upload, not a verified picture. The copy is sent by a browser using the key embedded in your site, which anyone visiting the site can read. Anectico checks that the copy can do nothing and, when text is masked, that it contains no readable text; it cannot check that the copy shows your real page. If an image looks wrong, ask your agent to delete it.
  • It will not look exactly like your page. Text is drawn in a generic font (your font names are not copied), so line breaks can differ. A grid laid out with named areas or named lines falls back to automatic placement, which can move or resize its regions. Web fonts, animations, shadows, visual filters, CSS counters and detailed vector graphics are left out, and style rules that select an element by its id do not apply, because ids are not copied.
  • A project keeps copies for up to 500 pages, counting each viewport class and service separately. When a new page needs room, a copy nobody has opened in a heatmap is dropped first, then the one opened longest ago.
  • Embedded images are limited in size. With inline_images on, an image larger than 4,096 pixels a side or four megapixels is left out.
  • If a page image ever stops the page from responding, it is skipped the next time in that browser. The proof page says so and lets you try drawing it again. To remove the image, ask your agent to delete the page backdrop; that needs replay:delete.
  • Some content is not copied: drawings on a canvas, video, content of embedded frames, components that hide their internals (closed shadow DOM), and stylesheets served from another address that the browser may not read. These appear empty or unstyled.
  • A very large page is copied in part or not at all. A copy holds at most 20,000 elements and pieces of text, and stops there; one larger than 2 MiB is not sent.
  • Selector masking is done in the visitor's browser. mask_all_text is verified by the server; mask_selectors and block_selectors cannot be, so leave mask_all_text on unless you have checked what your pages show.
  • The catch-all /[unmapped] page never gets a copy: it is many pages under one name.

When you erase a person, every copy their browser captured is deleted with the rest. A copy is not part of a person's data export: it describes your page, not the visitor.

Define a measurement

A heatmap definition is the common definition (version, window, timezone, optional environment) plus one heatmap object. A heatmap has no interval: its window is one span.

Field Meaning
heatmap.page Required. One declared route template, compared exactly with the event's $pathname: /pricing, /orders/:id. Never a URL, a query string or a title. /[unmapped] is the SDK's label for every path no template matched, and is a page like any other. It starts with / and is at most 256 bytes.
heatmap.mode Required. clicks, elements or scroll.
heatmap.viewport_width_bucket Optional, clicks and elements only: one viewport width class, named by its lower edge in pixels — 0, 576, 768, 1024 or 1440. 0 is the smallest class, not "unset"; leave the field out for every class together.
heatmap.service_name Read only the events whose $service_name equals it (1 to 128 bytes).
heatmap.include_rage_clicks clicks and elements only: also count rage clicks, beside clicks and never added to them.
heatmap.grid clicks only: {"columns": 20, "rows": 40}, the resolution of the grid. 1 to 50 columns by 1 to 200 rows, both set together. Left out, it is 20 by 40.
heatmap.limit elements only: how many elements to return, 1 to 100; empty or 0 means 20.

Person-property filters, audiences (cohorts) and environment restrict which people are measured and apply as usual. A heatmap is person-unit only: the account unit is refused with QUERY_UNIT_UNSUPPORTED. A field that belongs to another mode is refused, not ignored.

Examples

Where people click on the pricing page, on the default 20 by 40 grid, in a 7-day window:

{
  "version": 2,
  "timezone": "UTC",
  "window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-09-08T00:00:00Z"}},
  "heatmap": {"page": "/pricing", "mode": "clicks"}
}

Clicks on a phone-sized layout only, on a coarser 10 by 20 grid, with rage clicks:

{
  "version": 2,
  "timezone": "UTC",
  "window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-09-08T00:00:00Z"}},
  "heatmap": {
    "page": "/pricing",
    "mode": "clicks",
    "viewport_width_bucket": 0,
    "include_rage_clicks": true,
    "grid": {"columns": 10, "rows": 20}
  }
}

The ten most clicked elements of an order page, for one service, in production only:

{
  "version": 2,
  "timezone": "UTC",
  "environment": "production",
  "window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-09-08T00:00:00Z"}},
  "heatmap": {"page": "/orders/:id", "mode": "elements", "service_name": "storefront", "limit": 10}
}

How far people scroll the documentation home page:

{
  "version": 2,
  "timezone": "UTC",
  "window": {"absolute": {"start": "2026-09-01T00:00:00Z", "end": "2026-09-08T00:00:00Z"}},
  "heatmap": {"page": "/docs", "mode": "scroll"}
}

Clicks

The grid and the cell rule

A click has a position when it carries both $pointer_document_x_percent and $pointer_document_y_percent as numbers from 0 to 100: its place on the whole document, as a share of the document's width and height. The cell it counts in is

column = min(floor(x * columns / 100), columns - 1)
row    = min(floor(y * rows    / 100), rows    - 1)

both counted from 0 at the top left. A cell owns its lower boundary. A click exactly on the line between two cells belongs to the cell on the right or below it, 0 belongs to the first cell and 100 to the last. On a 10 by 10 grid a click at (10%, 10%) is in column 1, row 1, and one at (49.9%, 19.9%) is in column 4, row 1. (The proof page numbers cells from 1, so that cell is "column 2, row 2" there.)

A cell nobody clicked is never returned. A result lists the cells that hold at least one click (or rage click), most clicked first, then by rage clicks, then by row and column.

Clicks without a position

Every measured click is in exactly one of three groups, reported in positions:

Group Meaning
positioned Carried a valid position; it is in exactly one cell.
without_position Carried no position because pointer capture was off, or because a keyboard or assistive activation had no physical pointer location. It is counted in the totals and the elements, and in no cell.
invalid Carried only one coordinate, a value that is not a number, or a number outside 0 to 100. Counted, and in no cell.

positioned + without_position + invalid is always the click total. Clicks without a position are never dropped and never placed anywhere; the proof page says how many there are and what to do: turn on capturePointer, and check that the project's capture settings allow it. If rage clicks are counted, rage_positions accounts for them the same way.

Keyboard and assistive activations remain without a position even with pointer capture enabled. They retain the viewport width class when capture is enabled, so viewport filters can still count them. Real mouse, touch and pen clicks at coordinate zero are valid positioned clicks.

Rage clicks

A rage click is the SDK's record that one element received three physical mouse, touch or pen clicks within one second and 40 pixels. Keyboard and assistive activations without a physical pointer do not contribute to the burst. The burst's clicks are already counted as clicks, so rage_clicks is a separate count, reported beside clicks and never added to it. A cell or element reports its own rage_clicks; they are zero unless the definition sets include_rage_clicks. A cell that only holds rage clicks is returned, with zero clicks. If click friction is running, a burst on an element it labels is reported by friction and not as a rage click, so it is not counted here.

Viewports, and why not to blend layouts

A page is laid out differently at 400 px and at 1440 px: the same position can be a different button, or empty space. $viewport_width_bucket is the lower edge of the visitor's viewport width class — under 576 px (0), 576–767 px, 768–1023 px, 1024–1439 px and 1440 px and wider. A click carries it only when pointer capture is on.

  • With viewport_width_bucket, clicks and elements count only the clicks of that class.
  • Without it they count the clicks of every class together. The result then says so: viewports lists every class with its clicks and included: true, and the clicks that carried no valid class as one unknown entry. A blended grid describes none of the layouts, so look at one class at a time when more than one contributed.

Either way viewports lists every class seen on the page, with included: false for those a filter left out, so a filter never hides what it removed. The people of the whole measurement — the window population — are everyone who clicked on the page in any class; totals.clickers is the filtered subset.

Other and truncation

A result returns at most 2,000 cells. A finer grid on a busy page can hold more cells with clicks than that; the rest are one other entry with truncated: true. Its count is how many cells were left out, and its clickers are the distinct people over all of them. The default 20 by 40 grid has 800 cells and is never truncated. The cells and other together account for every positioned click.

Elements

The elements mode counts clicks by the clicked element. Each row is:

Field Meaning
selector The click's $selector: the SDK's stable CSS path, 1 to 512 bytes.
tag_name The element's $tag_name. If the clicks of one selector disagree, the lexicographically smallest; empty when none carried a valid one.
measures clicks, rage_clicks and clickers, as for a cell.

Rows are ranked by clicks, then rage clicks, then selector. Three rows are special:

  • The missing-selector row (missing: true, empty selector) holds the clicks that carried no valid selector: none, an empty one, or one longer than 512 bytes. It is always returned and does not count against limit.
  • other (with truncated: true) is the distinct union of the elements limit left out.
  • The rows, the missing row and other together account for every measured click.

The viewport rule above applies to elements too: without viewport_width_bucket the classes are merged and viewports says which.

No element text. A selector and a tag are all that is known of an element. Its text, label and attributes are customer content that heatmaps never read, so a result, a report or an export can never carry them.

Scroll

The scroll mode reads the page's $pageview and $pageleave events under the same rule as web analytics:

  • A page leave that reports its time and scroll depth is measured. It joins the latest page view of the same visitor, session and page with a strictly earlier timestamp.
  • A page view's depth is the maximum $scroll_depth_percent of its measured leaves. A page that becomes hidden and visible again can leave several times, so the deepest wins. The depth is the bottom of the viewport as a share of the page height; a page that fits the viewport reports 100.
  • A view with no measured leave has no depth. It is counted in views_without_leave, in no band, and never as depth zero.

page_views = measured_views + views_without_leave. The result carries visitors (everyone with a page view), leaves — every page leave of the page accounted for as attributed, unattributed (no earlier page view to join) or invalid (no usable time or depth) — and ten bands, at 10%, 20% … 100%. A view reaches a band when its depth is at least the threshold, so a depth exactly on a threshold reaches it and views never rises from one band to the next. Each band reports the views that reached it and the distinct visitors who own them.

The share of measured views that reached a depth is views / measured_views. The platform computes no percentage: the proof page shows it with the denominator, and shows a dash, not 0%, when no view was measured.

The people behind every count

Every population in a result — the totals, every returned cell, every element row including the missing one, each other entry, and every scroll band — carries {people, unresolved, selection_id}. people are resolved people; unresolved are identities that never resolved to a person: an occurrence has no identifier, or its identifier has no person mapping. An anonymous visitor can still be a resolved person. They are counted separately and never merged. The selection_id pages the resolved people behind the count, with the same call as any other kind, and each selection can be exported or turned into a derived audience.

A person who clicked in several cells is in each of their selections, so the people of cells and elements do not add up to the total. Anonymous clickers with resolved identifiers count in people and can be listed. If none of the identifiers resolves to a person, people is 0 and unresolved carries the unmatched count.

Result states

Missing data is never a measured zero. The result has a state and a closed flag (the end of the window against the frozen observation boundary):

State Meaning
PRODUCT_HEATMAP_STATE_MEASURED The window has ended and platform coverage is sufficient.
PRODUCT_HEATMAP_STATE_IMMATURE The window has not ended. The counts observed so far are disclosed and may still grow.
PRODUCT_HEATMAP_STATE_INSUFFICIENT_EVIDENCE The window has ended but platform coverage is not sufficient, for example because events were still arriving. The observed counts are disclosed and may undercount.
PRODUCT_HEATMAP_STATE_UNAVAILABLE Platform coverage is unavailable. Nothing is disclosed: no cells, elements, bands, totals or selections.

Only MEASURED means complete; the proof page states the other states in words above the numbers.

Permissions

Page routes, element selectors and click positions describe your product and are customer-supplied content. Reading any heatmap 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_HEATMAP_INVALID A missing or malformed page; an unknown mode; a viewport_width_bucket that is not 0, 576, 768, 1024 or 1440; a grid with only one of columns and rows; a field of another mode (grid outside clicks, limit outside elements, a viewport or include_rage_clicks with scroll); a service_name that is blank or too long; a breakdown, a comparison window, top-level event filters, a filter_group or account filters.
QUERY_HEATMAP_LIMIT A grid above 50 columns by 200 rows, or a limit above 100.
QUERY_UNIT_UNSUPPORTED The account unit.

Event-property filters are refused because one would remove some clicks of a cell, or some leaves of a view, and leave the rest. To narrow by an event property, use the page, the viewport class or the service.

Where a heatmap result is accepted

Feature Heatmaps
Result export Accepted: the people of the totals, a cell, an element, the other entry or a band export like any other population.
Saved insights and dashboard widgets The recipe is saved, with a relative window if you like, and shown as its own result. A widget shows a compact summary: the totals and the top cells, elements or bands, not the full grid.
Scheduled reports Accepted: counts and the top elements only. A click grid is never listed in a report; a merge of viewport classes is stated in a note; views without a depth are a figure beside the bands.
Remeasurement Accepted through MCP, the CLI and REST.
Metric watches Refused with UNSUPPORTED_RESULT_SELECTOR: a watch names no cell, element or band.
Live screens Refused with UNSUPPORTED_RESULT_SELECTOR.

There is no per-click drill-down: analytics result contribution refuses a heatmap selection, so only the roster of people behind a count is available. To watch one visit, use session replay.

Limits

Limit Value
Grid (heatmap.grid) 1 to 50 columns by 1 to 200 rows; 20 by 40 when omitted
Cells returned by one clicks result 2,000, most clicked first; the rest in other
Elements returned (heatmap.limit) 1 to 100; 20 when omitted. The missing-selector row is extra
Page 256 bytes
Service name 128 bytes
One selector 512 bytes; longer is counted as no selector
One tag name 64 bytes; longer is no tag
Scroll bands Ten, fixed: 10% to 100%

The measurement limits shared by every kind (measurements in flight, time to run, result lifetime) are in Limits. A measurement whose people would add up to more than 20,000,000 participant rows across all of its selections fails with PRODUCT_RESULT_FAILURE_REASON_EXECUTION_BUDGET rather than publishing part of a result; use a coarser grid or a shorter window. anectico analytics capabilities reports the heatmap kind with its heatmap_modes, heatmap_viewport_width_buckets and heatmap_scroll_bands, and the max_heatmap_columns, max_heatmap_rows, default_heatmap_columns, default_heatmap_rows, max_heatmap_cells, max_heatmap_elements, default_heatmap_elements and max_heatmap_participant_rows bounds.

Run it and read the result

anectico --project PROJECT_UUID analytics query --file heatmap.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 heatmap, 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}. Counts are decimal strings.

A trimmed clicks result on a 10 by 10 grid, for a page where a visitor clicked button#buy three times near the top left of a wide layout and another clicked it once on a narrow one, someone else clicked a link further right, one click carried no position and one carried an impossible one:

{
  "status": "PRODUCT_RESULT_STATUS_READY",
  "heatmap": {
    "state": "PRODUCT_HEATMAP_STATE_MEASURED",
    "closed": true,
    "clicks": {
      "totals": {
        "clicks": "7",
        "rage_clicks": "0",
        "clickers": {"people": "0", "unresolved": "4", "selection_id": "SELECTION_UUID"}
      },
      "positions": {"positioned": "5", "without_position": "1", "invalid": "1"},
      "rage_positions": null,
      "cells": [
        {"column": 1, "row": 1, "measures": {"clicks": "4", "rage_clicks": "0", "clickers": {"people": "0", "unresolved": "2", "selection_id": "SELECTION_UUID"}}},
        {"column": 5, "row": 2, "measures": {"clicks": "1", "rage_clicks": "0", "clickers": {"people": "0", "unresolved": "1", "selection_id": "SELECTION_UUID"}}}
      ],
      "other": null,
      "truncated": false,
      "viewports": [
        {"bucket": 576, "unknown": false, "clicks": "1", "rage_clicks": "0", "included": true},
        {"bucket": 1440, "unknown": false, "clicks": "6", "rage_clicks": "0", "included": true}
      ]
    }
  }
}

Here five of seven clicks have a position, one has none and one is invalid; the busiest cell, column 1 and row 1, holds four clicks from two clickers; two layouts are merged (the narrow one with one click, the wide one with six); and no clicker's identifier resolved to a person, so every clicker is unresolved. Asking again for viewport_width_bucket: 576 would count one click in one cell and list the 1440 class with included: false.

A trimmed scroll result for a page with three views, two of which reported a leave:

{
  "status": "PRODUCT_RESULT_STATUS_READY",
  "heatmap": {
    "state": "PRODUCT_HEATMAP_STATE_MEASURED",
    "closed": true,
    "scroll": {
      "page_views": "3",
      "measured_views": "2",
      "views_without_leave": "1",
      "visitors": {"people": "3", "unresolved": "0", "selection_id": "SELECTION_UUID"},
      "bands": [
        {"depth_percent": 10, "views": "2", "visitors": {"people": "2", "unresolved": "0", "selection_id": "SELECTION_UUID"}},
        {"depth_percent": 40, "views": "1", "visitors": {"people": "1", "unresolved": "0", "selection_id": "SELECTION_UUID"}},
        {"depth_percent": 100, "views": "0", "visitors": {"people": "0", "unresolved": "0", "selection_id": "SELECTION_UUID"}}
      ],
      "leaves": {"leaves": "3", "attributed": "3", "unattributed": "0", "invalid": "0"}
    }
  }
}

(All ten bands are returned; three are shown.) Both measured views reached 10%, only one reached 40%, none reached 100%, and the third view has no depth at all. The shares are 100%, 50% and 0% of the two measured views.

Page the people behind a count

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.

What heatmaps do not do

  • No screenshot, and nothing loaded from your site. The measurement reads no page content. The optional page behind the grid is a masked structural copy that a visitor's browser captured: a wireframe unless you turn images on, off unless you turn it on, and never an input value. The cells are shares of the document's width and height either way.
  • No element text or labels. Only a selector and a tag are known of an element; text, labels and attributes are never read.
  • No device, browser or operating-system split. The only split by device is the viewport width class. There is no country, device type or browser dimension.
  • No mouse movement, hover, attention or scroll-position replay. Heatmaps count clicks, rage clicks, page views and page leaves. Where the pointer went between clicks is not recorded.
  • No session-level overlay. A heatmap aggregates visitors. To watch one visit, use session replay.
  • Only what was captured. A visitor who declined product analytics is not measured, an element you blocked sends nothing, and clicks outside one of your declared routes are recorded under the literal /[unmapped].

To measure which pages visitors come from and leave, see web analytics; for how real users experience your pages, see customer experience.