From c229ae13efc65668018046f429e40894364f9b05 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Mon, 1 Jun 2026 10:57:33 -0700 Subject: [PATCH] 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. --- .../src/lib/analytics-clickmap-query.test.ts | 60 ++++++++++ .../src/lib/analytics-clickmap-query.ts | 104 ++++++++++++------ packages/stack-shared/src/utils/dom.tsx | 32 +++++- .../template/src/dev-tool/dev-tool-styles.ts | 2 +- 4 files changed, 163 insertions(+), 35 deletions(-) diff --git a/apps/backend/src/lib/analytics-clickmap-query.test.ts b/apps/backend/src/lib/analytics-clickmap-query.test.ts index d77e3abae..5274198ff 100644 --- a/apps/backend/src/lib/analytics-clickmap-query.test.ts +++ b/apps/backend/src/lib/analytics-clickmap-query.test.ts @@ -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, + }, + ], + } + `); + }); }); diff --git a/apps/backend/src/lib/analytics-clickmap-query.ts b/apps/backend/src/lib/analytics-clickmap-query.ts index 4d715ba5c..6a6e2a4bb 100644 --- a/apps/backend/src/lib/analytics-clickmap-query.ts +++ b/apps/backend/src/lib/analytics-clickmap-query.ts @@ -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(); }; - const routesQuery = runJson<{ path: string, clicks: number | string, users: number | string, replays: number | string }>(` + const routesQuery = runJson(` 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(` 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(` 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(` 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(` 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, + }); } diff --git a/packages/stack-shared/src/utils/dom.tsx b/packages/stack-shared/src/utils/dom.tsx index 776b8d468..c077f3c3b 100644 --- a/packages/stack-shared/src/utils/dom.tsx +++ b/packages/stack-shared/src/utils/dom.tsx @@ -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; } diff --git a/packages/template/src/dev-tool/dev-tool-styles.ts b/packages/template/src/dev-tool/dev-tool-styles.ts index 3cf12b636..77f4adaa3 100644 --- a/packages/template/src/dev-tool/dev-tool-styles.ts +++ b/packages/template/src/dev-tool/dev-tool-styles.ts @@ -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); }