stack/apps/backend/src/lib/analytics-clickmap-query.test.ts
Mantra e93b7520c4
feat(analytics): add route analytics heatmaps (#1520)
## Summary

Adds route analytics heatmaps, stacked on top of
`codex/analytics-overview-filters` (#1496).

- Heatmap API routes (`/analytics/heatmap`, internal heatmap +
heatmap-token endpoints)
- Signed heatmap token signing/verification lib + tests
- Dashboard heatmaps page (client + route)
- Dev-tool + event-tracker support for heatmap capture
- ClickHouse migration support

## Demo


https://app.devin.ai/attachments/49cd6a96-8962-46d9-b8fb-145746cc6dee/rec-c80ec66f-21a3-49fb-bfae-19195ce7b930-edited.mp4

## Notes
Base branch is `codex/analytics-overview-filters` so the diff shows only
the heatmap changes. Will retarget to `dev` once the base PR lands.

Link to Devin session:
https://app.devin.ai/sessions/16f8adac29b948b38280c85418617fea
Requested by: @mantrakp04

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

## Release Notes

* **New Features**
* Added clickmap overlay to analytics dashboard, enabling visual click
heatmap analysis on live websites.
* Enhanced analytics metrics with hourly breakdowns, bounce rates, and
top regions/browsers/devices filtering.

* **Bug Fixes**
  * Improved click event tracking accuracy and dead-click detection.
  * Fixed overlay z-index stacking for better visibility.

* **Style**
* Updated dashboard card padding and navigation button styling for
consistency.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mantra <mantra@stack-auth.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 12:06:16 -07:00

199 lines
7.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
buildClickmapUrlLikePattern,
buildHourOfWeekClickmapCells,
clampClickmapSampling,
getClickmapOriginFilter,
getClickmapOriginParams,
getClickmapRouteFilter,
getClickmapSystemElementFilter,
getClickmapUserAndReplayFilter,
getClickmapViewportFilter,
getDeviceViewportBucket,
normalizeClickmapClicksQueryRows,
} from "./analytics-clickmap-query";
describe("analytics clickmap query helpers", () => {
it("pads sparse hour-of-week rows into a complete 7x24 grid", () => {
const cells = buildHourOfWeekClickmapCells([
{ weekday: "1", hour: "0", value: "3" },
{ weekday: 7, hour: 23, value: 9 },
]);
expect(cells).toHaveLength(168);
expect(cells[0]).toMatchInlineSnapshot(`
{
"hour": 0,
"value": 3,
"weekday": 1,
}
`);
expect(cells[167]).toMatchInlineSnapshot(`
{
"hour": 23,
"value": 9,
"weekday": 7,
}
`);
expect(cells[1]).toMatchInlineSnapshot(`
{
"hour": 1,
"value": 0,
"weekday": 1,
}
`);
});
it("ignores invalid ClickHouse bucket rows", () => {
const cells = buildHourOfWeekClickmapCells([
{ weekday: 0, hour: 12, value: 10 },
{ weekday: 1, hour: 24, value: 10 },
{ weekday: 2, hour: 3, value: 4 },
]);
expect(cells.find((cell) => cell.weekday === 2 && cell.hour === 3)).toMatchInlineSnapshot(`
{
"hour": 3,
"value": 4,
"weekday": 2,
}
`);
expect(cells.filter((cell) => cell.value !== 0)).toHaveLength(1);
});
it("returns no viewport bucket when no device is selected", () => {
expect(getDeviceViewportBucket(undefined)).toBeNull();
expect(getDeviceViewportBucket("")).toBeNull();
expect(getDeviceViewportBucket("not-a-device")).toBeNull();
});
it("expands device classes into viewport bucket bounds", () => {
expect(getDeviceViewportBucket("mobile")).toMatchInlineSnapshot(`
{
"max": 767,
"min": 0,
}
`);
expect(getDeviceViewportBucket("widescreen")).toMatchInlineSnapshot(`
{
"max": 1919,
"min": 1440,
}
`);
});
it("emits a viewport filter only for the bounds that are set", () => {
expect(getClickmapViewportFilter(undefined, undefined)).toMatchInlineSnapshot(`""`);
expect(getClickmapViewportFilter(768, undefined)).toMatchInlineSnapshot(`"AND viewport_width >= {viewportWidthMin:UInt32}"`);
expect(getClickmapViewportFilter(undefined, 1023)).toMatchInlineSnapshot(`"AND viewport_width <= {viewportWidthMax:UInt32}"`);
expect(getClickmapViewportFilter(768, 1023)).toMatchInlineSnapshot(`"AND viewport_width >= {viewportWidthMin:UInt32} AND viewport_width <= {viewportWidthMax:UInt32}"`);
});
it("prefers a route regex over a url pattern over an exact route", () => {
expect(getClickmapRouteFilter("/x", "^/x", "/x/%")).toMatchInlineSnapshot(`"AND match(path, {routeRegex:String})"`);
expect(getClickmapRouteFilter("/x", undefined, "/x/%")).toMatchInlineSnapshot(`"AND path LIKE {urlPatternLike:String}"`);
expect(getClickmapRouteFilter("/x", undefined, null)).toMatchInlineSnapshot(`"AND path = {routePath:String}"`);
expect(getClickmapRouteFilter(undefined, undefined, null)).toMatchInlineSnapshot(`""`);
});
it("translates `*` wildcards into SQL LIKE while escaping `_`/`%`/`\\\\`", () => {
expect(buildClickmapUrlLikePattern(undefined)).toBeNull();
expect(buildClickmapUrlLikePattern("")).toBeNull();
expect(buildClickmapUrlLikePattern("/products/*")).toMatchInlineSnapshot(`"/products/%"`);
expect(buildClickmapUrlLikePattern("/path%/_*")).toMatchInlineSnapshot(`"/path\\%/\\_%"`);
expect(buildClickmapUrlLikePattern("/api/v*/users/*")).toMatchInlineSnapshot(`"/api/v%/users/%"`);
});
it("binds clickmap user/replay filters as nullable to match the MV schema", () => {
expect(getClickmapUserAndReplayFilter("user-123", "replay-123")).toMatchInlineSnapshot(`"AND user_id = {userId:Nullable(String)} AND session_replay_id = {replayId:Nullable(String)}"`);
expect(getClickmapUserAndReplayFilter("user-123", undefined)).toMatchInlineSnapshot(`"AND user_id = {userId:Nullable(String)}"`);
expect(getClickmapUserAndReplayFilter(undefined, "replay-123")).toMatchInlineSnapshot(`"AND session_replay_id = {replayId:Nullable(String)}"`);
expect(getClickmapUserAndReplayFilter(undefined, undefined)).toMatchInlineSnapshot(`""`);
});
it("scopes public clickmap queries to the exact token origin", () => {
expect(getClickmapOriginFilter()).toMatchInlineSnapshot(`"AND (url = {origin:String} OR startsWith(url, {originSlashPrefix:String}) OR startsWith(url, {originQueryPrefix:String}) OR startsWith(url, {originHashPrefix:String}))"`);
expect(getClickmapOriginParams("https://app.example.com")).toMatchInlineSnapshot(`
{
"origin": "https://app.example.com",
"originHashPrefix": "https://app.example.com#",
"originQueryPrefix": "https://app.example.com?",
"originSlashPrefix": "https://app.example.com/",
}
`);
});
it("excludes Hexclave dev tool clicks from clickmap queries", () => {
expect(getClickmapSystemElementFilter()).toMatchInlineSnapshot(`"AND position(elements_chain, '__hexclave-dev-tool-root') = 0 AND position(elements_chain, 'stack-devtool') = 0 AND position(elements_chain, 'sdt-') = 0 AND position(selector, '#__hexclave-dev-tool-root') = 0 AND position(selector, '.stack-devtool') = 0 AND position(selector, '.sdt-') = 0"`);
});
it("clamps sampling to (0, 1] with finite default", () => {
expect(clampClickmapSampling(undefined)).toBe(1);
expect(clampClickmapSampling(0)).toBe(0.01);
expect(clampClickmapSampling(-1)).toBe(0.01);
expect(clampClickmapSampling(0.25)).toBe(0.25);
expect(clampClickmapSampling(2)).toBe(1);
expect(clampClickmapSampling(Number.NaN)).toBe(1);
});
it("only scales sampled event counts, not unique users or replays", () => {
const result = normalizeClickmapClicksQueryRows({
samplingPct: 25,
routesRows: [{ path: "/pricing", clicks: "10", users: "8", replays: "3" }],
selectorsRows: [{ selector: "button.primary", clicks: "4" }],
elementsRows: [{ elements_chain: "button.primary", elements_text: "Buy", tag_name: "button", href: null, clicks: "5", dead_clicks: "2" }],
userRows: [{ id: "user-123", clicks: "6", replays: "2", last_event_at_millis: "1710000000000" }],
replayRows: [{ id: "replay-123", linked_user_id: "user-123", route_path: "/pricing", viewport_width: "1440", viewport_height: "900", clicks: "7", last_event_at_millis: "1710000000123" }],
});
expect(result).toMatchInlineSnapshot(`
{
"elements": [
{
"clicks": 20,
"dead_clicks": 8,
"elements_chain": "button.primary",
"elements_text": "Buy",
"href": null,
"tag_name": "button",
},
],
"replays": [
{
"clicks": 28,
"id": "replay-123",
"last_event_at_millis": 1710000000123,
"linked_user_id": "user-123",
"route_path": "/pricing",
"viewport_height": 900,
"viewport_width": 1440,
},
],
"routes": [
{
"clicks": 40,
"path": "/pricing",
"replays": 3,
"users": 8,
},
],
"samplingPct": 25,
"selectors": [
{
"clicks": 16,
"selector": "button.primary",
},
],
"users": [
{
"clicks": 24,
"id": "user-123",
"last_event_at_millis": 1710000000000,
"replays": 2,
},
],
}
`);
});
});