feat(analytics): implement normalization for clickmap query results

- Added `normalizeClickmapClicksQueryRows` function to standardize the processing of clickmap query results.
- Updated types to include raw data representations for routes, selectors, elements, users, and replays.
- Enhanced `runClickmapClicksQuery` to utilize the new normalization function, improving data handling and consistency.
- Introduced tests for the normalization function to ensure accurate scaling of sampled event counts while preserving unique user and replay counts.
This commit is contained in:
mantrakp04 2026-06-01 10:57:33 -07:00
parent 1f10e19b73
commit c229ae13ef
4 changed files with 163 additions and 35 deletions

View File

@ -10,6 +10,7 @@ import {
getClickmapUserAndReplayFilter,
getClickmapViewportFilter,
getDeviceViewportBucket,
normalizeClickmapClicksQueryRows,
} from "./analytics-clickmap-query";
describe("analytics clickmap query helpers", () => {
@ -134,4 +135,63 @@ describe("analytics clickmap query helpers", () => {
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" }],
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,
"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,
},
],
}
`);
});
});

View File

@ -201,9 +201,13 @@ export type ClickmapClicksQueryInput = {
};
type ClickmapRouteRow = { path: string, clicks: number, users: number, replays: number };
type ClickmapRawRouteRow = { path: string, clicks: number | string, users: number | string, replays: number | string };
type ClickmapSelectorRow = { selector: string, clicks: number };
type ClickmapRawSelectorRow = { selector: string, clicks: number | string };
type ClickmapElementRow = { elements_chain: string, elements_text: string, tag_name: string, href: string | null, clicks: number };
type ClickmapRawElementRow = { elements_chain: string, elements_text: string, tag_name: string, href: string | null, clicks: number | string };
type ClickmapUserRow = { id: string, clicks: number, replays: number, last_event_at_millis: number };
type ClickmapRawUserRow = { id: string, clicks: number | string, replays: number | string, last_event_at_millis: number | string };
type ClickmapReplayRow = {
id: string,
linked_user_id: string | null,
@ -213,6 +217,15 @@ type ClickmapReplayRow = {
clicks: number,
last_event_at_millis: number,
};
type ClickmapRawReplayRow = {
id: string,
linked_user_id: string | null,
route_path: string | null,
viewport_width: number | string | null,
viewport_height: number | string | null,
clicks: number | string,
last_event_at_millis: number | string,
};
export type ClickmapClicksQueryResult = {
samplingPct: number,
@ -224,6 +237,52 @@ export type ClickmapClicksQueryResult = {
replays: ClickmapReplayRow[],
};
export function normalizeClickmapClicksQueryRows(input: {
samplingPct: number,
routesRows: ClickmapRawRouteRow[],
selectorsRows: ClickmapRawSelectorRow[],
elementsRows: ClickmapRawElementRow[],
userRows: ClickmapRawUserRow[],
replayRows: ClickmapRawReplayRow[],
}): ClickmapClicksQueryResult {
const samplingScale = 100 / input.samplingPct;
const scaleSampledEventCount = (value: number | string) => Math.round(Number(value) * samplingScale);
const exactUniqueCount = (value: number | string) => Number(value);
return {
samplingPct: input.samplingPct,
routes: input.routesRows.map((row) => ({
path: row.path,
clicks: scaleSampledEventCount(row.clicks),
users: exactUniqueCount(row.users),
replays: exactUniqueCount(row.replays),
})),
selectors: input.selectorsRows.map((row) => ({ selector: row.selector, clicks: scaleSampledEventCount(row.clicks) })),
elements: input.elementsRows.map((row) => ({
elements_chain: row.elements_chain,
elements_text: row.elements_text,
tag_name: row.tag_name,
href: row.href,
clicks: scaleSampledEventCount(row.clicks),
})),
users: input.userRows.map((row) => ({
id: row.id,
clicks: scaleSampledEventCount(row.clicks),
replays: exactUniqueCount(row.replays),
last_event_at_millis: Number(row.last_event_at_millis),
})),
replays: input.replayRows.map((row) => ({
id: row.id,
linked_user_id: row.linked_user_id,
route_path: row.route_path,
viewport_width: row.viewport_width == null ? null : Number(row.viewport_width),
viewport_height: row.viewport_height == null ? null : Number(row.viewport_height),
clicks: scaleSampledEventCount(row.clicks),
last_event_at_millis: Number(row.last_event_at_millis),
})),
};
}
/**
* Build the shared WHERE/params, run the routes/selectors/elements aggregates
* (plus per-user/per-replay aggregates when `linkedLimit` is set), and return
@ -241,8 +300,6 @@ export async function runClickmapClicksQuery(
const viewportMax = input.viewportWidthMax ?? deviceBucket?.max;
const urlPatternLike = buildClickmapUrlLikePattern(input.urlPattern);
const samplingPct = Math.max(1, Math.round(clampClickmapSampling(input.sampling) * 100));
const samplingScale = 100 / samplingPct;
const scaleCount = (value: number | string) => Math.round(Number(value) * samplingScale);
const samplingClause = samplingPct < 100
? "AND intHash32(toUInt32(toUnixTimestamp(event_at)) + cityHash64(coalesce(toString(user_id), ''))) % 100 < {samplingPct:UInt32}"
@ -289,7 +346,7 @@ export async function runClickmapClicksQuery(
return await result.json<T>();
};
const routesQuery = runJson<{ path: string, clicks: number | string, users: number | string, replays: number | string }>(`
const routesQuery = runJson<ClickmapRawRouteRow>(`
SELECT
path,
count() AS clicks,
@ -304,7 +361,7 @@ export async function runClickmapClicksQuery(
LIMIT {routeLimit:UInt32}
`);
const selectorsQuery = runJson<{ selector: string, clicks: number | string }>(`
const selectorsQuery = runJson<ClickmapRawSelectorRow>(`
SELECT
nullIf(selector, '') AS selector,
count() AS clicks
@ -317,7 +374,7 @@ export async function runClickmapClicksQuery(
LIMIT {routeLimit:UInt32}
`);
const elementsQuery = runJson<{ elements_chain: string, elements_text: string, tag_name: string, href: string | null, clicks: number | string }>(`
const elementsQuery = runJson<ClickmapRawElementRow>(`
SELECT
elements_chain,
any(elements_text) AS elements_text,
@ -333,7 +390,7 @@ export async function runClickmapClicksQuery(
LIMIT {elementsChainLimit:UInt32}
`);
const usersQuery = input.linkedLimit == null ? null : runJson<{ id: string, clicks: number | string, replays: number | string, last_event_at_millis: number | string }>(`
const usersQuery = input.linkedLimit == null ? null : runJson<ClickmapRawUserRow>(`
SELECT
assumeNotNull(user_id) AS id,
count() AS clicks,
@ -348,7 +405,7 @@ export async function runClickmapClicksQuery(
LIMIT {linkedLimit:UInt32}
`);
const replaysQuery = input.linkedLimit == null ? null : runJson<{ id: string, linked_user_id: string | null, route_path: string | null, viewport_width: number | string | null, viewport_height: number | string | null, clicks: number | string, last_event_at_millis: number | string }>(`
const replaysQuery = input.linkedLimit == null ? null : runJson<ClickmapRawReplayRow>(`
SELECT
assumeNotNull(session_replay_id) AS id,
any(user_id) AS linked_user_id,
@ -374,31 +431,12 @@ export async function runClickmapClicksQuery(
replaysQuery ?? Promise.resolve([]),
]);
return {
return normalizeClickmapClicksQueryRows({
samplingPct,
routes: routesRows.map((row) => ({ path: row.path, clicks: scaleCount(row.clicks), users: scaleCount(row.users), replays: scaleCount(row.replays) })),
selectors: selectorsRows.map((row) => ({ selector: row.selector, clicks: scaleCount(row.clicks) })),
elements: elementsRows.map((row) => ({
elements_chain: row.elements_chain,
elements_text: row.elements_text,
tag_name: row.tag_name,
href: row.href,
clicks: scaleCount(row.clicks),
})),
users: userRows.map((row) => ({
id: row.id,
clicks: scaleCount(row.clicks),
replays: scaleCount(row.replays),
last_event_at_millis: Number(row.last_event_at_millis),
})),
replays: replayRows.map((row) => ({
id: row.id,
linked_user_id: row.linked_user_id,
route_path: row.route_path,
viewport_width: row.viewport_width == null ? null : Number(row.viewport_width),
viewport_height: row.viewport_height == null ? null : Number(row.viewport_height),
clicks: scaleCount(row.clicks),
last_event_at_millis: Number(row.last_event_at_millis),
})),
};
routesRows,
selectorsRows,
elementsRows,
userRows,
replayRows,
});
}

View File

@ -15,5 +15,35 @@ export function cssEscapeIdent(value: string): string {
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
return CSS.escape(value);
}
return value.replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`);
let escaped = "";
for (let i = 0; i < value.length; i += 1) {
const char = value.charAt(i);
const codeUnit = value.charCodeAt(i);
if (codeUnit === 0x0000) {
escaped += "\uFFFD";
} else if (
(codeUnit >= 0x0001 && codeUnit <= 0x001f) ||
codeUnit === 0x007f ||
(i === 0 && codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
(i === 1 && codeUnit >= 0x0030 && codeUnit <= 0x0039 && value.charCodeAt(0) === 0x002d)
) {
escaped += `\\${codeUnit.toString(16)} `;
} else if (i === 0 && codeUnit === 0x002d && value.length === 1) {
escaped += "\\-";
} else if (
codeUnit >= 0x0080 ||
codeUnit === 0x002d ||
codeUnit === 0x005f ||
(codeUnit >= 0x0030 && codeUnit <= 0x0039) ||
(codeUnit >= 0x0041 && codeUnit <= 0x005a) ||
(codeUnit >= 0x0061 && codeUnit <= 0x007a)
) {
escaped += char;
} else {
escaped += `\\${char}`;
}
}
return escaped;
}

View File

@ -2728,7 +2728,7 @@ export const devToolCSS = `
cursor: pointer;
}
.stack-devtool .sdt-hm-mode-btn:hover {
.stack-devtool .sdt-hm-mode-btn:not(.sdt-hm-mode-btn-active):hover {
color: var(--sdt-text);
}