mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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:
parent
1f10e19b73
commit
c229ae13ef
@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user