From 5b56689d67af2cf7c0157e00b3e91c3837ba57f1 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 28 May 2026 18:56:52 -0700 Subject: [PATCH 01/10] feat(analytics): add route analytics heatmaps Merge codex/route-heatmaps into the analytics overview filters branch, adding heatmap API routes, token signing, dashboard heatmap page, and dev-tool/event-tracker support. --- apps/backend/scripts/clickhouse-migrations.ts | 170 ++ .../app/api/latest/analytics/heatmap/route.ts | 236 +++ .../internal/analytics/heatmap-token/route.ts | 34 + .../internal/analytics/heatmap/route.test.ts | 142 ++ .../internal/analytics/heatmap/route.ts | 428 +++++ .../src/lib/analytics-heatmap-tokens.test.ts | 50 + .../src/lib/analytics-heatmap-tokens.ts | 94 ++ .../route-handlers/smart-route-handler.tsx | 2 + .../analytics/heatmaps/page-client.tsx | 485 ++++++ .../[projectId]/analytics/heatmaps/page.tsx | 5 + .../projects/[projectId]/page-layout.tsx | 2 +- .../projects/[projectId]/sidebar-layout.tsx | 2 +- .../teams/[teamId]/team-analytics.tsx | 36 +- apps/dashboard/src/lib/apps-frontend.tsx | 1 + .../src/interface/admin-interface.ts | 44 +- .../src/interface/admin-metrics.ts | 66 + .../template/src/dev-tool/dev-tool-core.ts | 1432 ++++++++++++++++- .../template/src/dev-tool/dev-tool-styles.ts | 429 ++++- .../apps/implementations/admin-app-impl.ts | 24 +- .../implementations/event-tracker.test.ts | 132 ++ .../apps/implementations/event-tracker.ts | 174 +- .../stack-app/apps/interfaces/admin-app.ts | 17 + 22 files changed, 3962 insertions(+), 43 deletions(-) create mode 100644 apps/backend/src/app/api/latest/analytics/heatmap/route.ts create mode 100644 apps/backend/src/app/api/latest/internal/analytics/heatmap-token/route.ts create mode 100644 apps/backend/src/app/api/latest/internal/analytics/heatmap/route.test.ts create mode 100644 apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts create mode 100644 apps/backend/src/lib/analytics-heatmap-tokens.test.ts create mode 100644 apps/backend/src/lib/analytics-heatmap-tokens.ts create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page.tsx diff --git a/apps/backend/scripts/clickhouse-migrations.ts b/apps/backend/scripts/clickhouse-migrations.ts index 8db2a40777..9031d1e05f 100644 --- a/apps/backend/scripts/clickhouse-migrations.ts +++ b/apps/backend/scripts/clickhouse-migrations.ts @@ -32,11 +32,21 @@ export async function runClickhouseMigrations() { client.command({ query: NOTIFICATION_PREFERENCES_TABLE_BASE_SQL }), client.command({ query: REFRESH_TOKENS_TABLE_BASE_SQL }), client.command({ query: CONNECTED_ACCOUNTS_TABLE_BASE_SQL }), + client.command({ query: CLICKMAP_EVENTS_TABLE_SQL }), ]); // Alter events table (must come before views that reference new columns) await client.command({ query: EVENTS_ADD_REPLAY_COLUMNS_SQL }); + // Clickmap materialized view depends on the events table existing; create after the ALTER above + // so the view sees the replay columns. IF NOT EXISTS makes this idempotent across reboots. + await client.command({ query: CLICKMAP_EVENTS_MV_SQL }); + + // Backfill historical $click rows that pre-date the MV. Predicate picks rows + // older than the earliest MV-captured row, so re-runs are no-ops once the + // first backfill completes. + await client.command({ query: CLICKMAP_EVENTS_BACKFILL_SQL }); + // Create all views in parallel await Promise.all([ client.command({ query: EVENTS_VIEW_SQL }), @@ -52,6 +62,7 @@ export async function runClickhouseMigrations() { client.command({ query: NOTIFICATION_PREFERENCES_VIEW_SQL }), client.command({ query: REFRESH_TOKENS_VIEW_SQL }), client.command({ query: CONNECTED_ACCOUNTS_VIEW_SQL }), + client.command({ query: CLICKMAP_EVENTS_VIEW_SQL }), ]); // Data migrations (mutations) @@ -66,6 +77,7 @@ export async function runClickhouseMigrations() { "events", "users", "contact_channels", "teams", "team_member_profiles", "team_permissions", "team_invitations", "email_outboxes", "project_permissions", "notification_preferences", "refresh_tokens", "connected_accounts", + "clickmap_events", ]; await Promise.all(tables.map(table => client.command({ @@ -648,3 +660,161 @@ WHERE sync_is_deleted = 0; const EXTERNAL_ANALYTICS_DB_SQL = ` CREATE DATABASE IF NOT EXISTS analytics_internal; `; + +// Clickmap-only physical table (PostHog-style schema). Fed by clickmap_events_mv +// from analytics_internal.events WHERE event_type='$click'. Backwards compatible +// with click rows that pre-date elements_chain / scaled coords: the MV derives +// pointer_* from raw data.x / data.y / data.page_y, and elements_chain falls +// back to the empty string when the SDK didn't emit one. +// +// SCALE_FACTOR = 16 mirrors PostHog: pixel coords are divided at ingest so +// downstream queries operate on small integers and partitions stay compact. +// +// Order key (project_id, branch_id, date, path, viewport_width) matches the +// hot clickmap query: "all clicks on this path in this date range at these +// viewport widths". +const CLICKMAP_EVENTS_TABLE_SQL = ` +CREATE TABLE IF NOT EXISTS analytics_internal.clickmap_events ( + project_id String, + branch_id String, + event_at DateTime64(3, 'UTC'), + user_id Nullable(String), + session_replay_id Nullable(String), + url String, + path String, + viewport_width UInt16, + viewport_height UInt16, + pointer_x UInt16, + pointer_y UInt16, + client_y UInt16, + pointer_relative_x Float32, + pointer_target_fixed UInt8, + elements_chain String, + selector String, + elements_text String, + tag_name LowCardinality(String), + href Nullable(String) +) +ENGINE MergeTree +PARTITION BY toYYYYMM(event_at) +ORDER BY (project_id, branch_id, toDate(event_at), path, viewport_width); +`; + +// Materialized view that auto-populates clickmap_events on every $click insert. +// No POPULATE clause: existing rows are not backfilled (they remain queryable +// via the existing /api/.../analytics/heatmap route which still reads from +// analytics_internal.events). New click rows flow into both tables. +// +// All field accesses use the toFloat64OrZero(toString(...)) pattern that the +// existing analytics queries use, so JSON-Variant nullability is handled the +// same way. +const CLICKMAP_EVENTS_MV_SQL = ` +CREATE MATERIALIZED VIEW IF NOT EXISTS analytics_internal.clickmap_events_mv +TO analytics_internal.clickmap_events +AS +SELECT + project_id, + branch_id, + event_at, + user_id, + session_replay_id, + toString(data.url) AS url, + toString(data.path) AS path, + toUInt16(least(65535, greatest(0, toUInt32(toFloat64OrZero(toString(data.viewport_width)))))) AS viewport_width, + toUInt16(least(65535, greatest(0, toUInt32(toFloat64OrZero(toString(data.viewport_height)))))) AS viewport_height, + toUInt16(least(65535, greatest(0, toUInt32( + coalesce(toFloat64OrNull(toString(data.x_scaled)), toFloat64OrZero(toString(data.page_x)) / 16, toFloat64OrZero(toString(data.x)) / 16) + )))) AS pointer_x, + toUInt16(least(65535, greatest(0, toUInt32( + coalesce(toFloat64OrNull(toString(data.y_scaled)), toFloat64OrZero(toString(data.page_y)) / 16, toFloat64OrZero(toString(data.y)) / 16) + )))) AS pointer_y, + toUInt16(least(65535, greatest(0, toUInt32( + coalesce(toFloat64OrNull(toString(data.client_y_scaled)), toFloat64OrZero(toString(data.y)) / 16) + )))) AS client_y, + toFloat32(coalesce( + toFloat64OrNull(toString(data.pointer_relative_x)), + if(toFloat64OrZero(toString(data.viewport_width)) > 0, + toFloat64OrZero(toString(data.x)) / toFloat64OrZero(toString(data.viewport_width)), + 0) + )) AS pointer_relative_x, + toUInt8(coalesce(toUInt8OrNull(toString(data.pointer_target_fixed)), 0)) AS pointer_target_fixed, + toString(data.elements_chain) AS elements_chain, + toString(data.selector) AS selector, + toString(data.text) AS elements_text, + toString(data.tag_name) AS tag_name, + nullIf(toString(data.href), '') AS href +FROM analytics_internal.events +WHERE event_type = '$click'; +`; + +// Idempotent backfill: insert pre-MV $click rows into clickmap_events. After +// the first run, min(event_at) in clickmap_events corresponds to the MV-capture +// start (or earlier, once historical rows land), so this predicate returns no +// new rows and the migration is a cheap no-op. +const CLICKMAP_EVENTS_BACKFILL_SQL = ` +INSERT INTO analytics_internal.clickmap_events +SELECT + project_id, + branch_id, + event_at, + user_id, + session_replay_id, + toString(data.url) AS url, + toString(data.path) AS path, + toUInt16(least(65535, greatest(0, toUInt32(toFloat64OrZero(toString(data.viewport_width)))))) AS viewport_width, + toUInt16(least(65535, greatest(0, toUInt32(toFloat64OrZero(toString(data.viewport_height)))))) AS viewport_height, + toUInt16(least(65535, greatest(0, toUInt32( + coalesce(toFloat64OrNull(toString(data.x_scaled)), toFloat64OrZero(toString(data.page_x)) / 16, toFloat64OrZero(toString(data.x)) / 16) + )))) AS pointer_x, + toUInt16(least(65535, greatest(0, toUInt32( + coalesce(toFloat64OrNull(toString(data.y_scaled)), toFloat64OrZero(toString(data.page_y)) / 16, toFloat64OrZero(toString(data.y)) / 16) + )))) AS pointer_y, + toUInt16(least(65535, greatest(0, toUInt32( + coalesce(toFloat64OrNull(toString(data.client_y_scaled)), toFloat64OrZero(toString(data.y)) / 16) + )))) AS client_y, + toFloat32(coalesce( + toFloat64OrNull(toString(data.pointer_relative_x)), + if(toFloat64OrZero(toString(data.viewport_width)) > 0, + toFloat64OrZero(toString(data.x)) / toFloat64OrZero(toString(data.viewport_width)), + 0) + )) AS pointer_relative_x, + toUInt8(coalesce(toUInt8OrNull(toString(data.pointer_target_fixed)), 0)) AS pointer_target_fixed, + toString(data.elements_chain) AS elements_chain, + toString(data.selector) AS selector, + toString(data.text) AS elements_text, + toString(data.tag_name) AS tag_name, + nullIf(toString(data.href), '') AS href +FROM analytics_internal.events +WHERE event_type = '$click' + AND event_at < coalesce( + (SELECT min(event_at) FROM analytics_internal.clickmap_events), + toDateTime64('2999-01-01 00:00:00', 3, 'UTC') + ); +`; + +const CLICKMAP_EVENTS_VIEW_SQL = ` +CREATE OR REPLACE VIEW default.clickmap_events +SQL SECURITY DEFINER +AS +SELECT + project_id, + branch_id, + event_at, + user_id, + session_replay_id, + url, + path, + viewport_width, + viewport_height, + pointer_x, + pointer_y, + client_y, + pointer_relative_x, + pointer_target_fixed, + elements_chain, + selector, + elements_text, + tag_name, + href +FROM analytics_internal.clickmap_events; +`; diff --git a/apps/backend/src/app/api/latest/analytics/heatmap/route.ts b/apps/backend/src/app/api/latest/analytics/heatmap/route.ts new file mode 100644 index 0000000000..1e2aa9749d --- /dev/null +++ b/apps/backend/src/app/api/latest/analytics/heatmap/route.ts @@ -0,0 +1,236 @@ +import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse"; +import { verifyAnalyticsHeatmapToken } from "@/lib/analytics-heatmap-tokens"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { ClickHouseError } from "@clickhouse/client"; +import { AnalyticsHeatmapResponseBodySchema, type AnalyticsHeatmapResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { HexclaveAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; +import { + buildClickmapUrlLikePattern, + clampClickmapSampling, + getClickmapOriginFilter, + getClickmapOriginParams, + getClickmapRouteFilter, + getClickmapSystemElementFilter, + getClickmapUserAndReplayFilter, + getClickmapViewportFilter, + getDeviceViewportBucket, +} from "../../internal/analytics/heatmap/route"; + +const MAX_WINDOW_DAYS = 31; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const ROUTE_LIMIT = 50; +const ELEMENTS_CHAIN_LIMIT = 200; + +function formatClickhouseDateTimeParam(date: Date): string { + return date.toISOString().slice(0, 19); +} + +function parseBoundedDateTime(value: string, name: string): Date { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) { + throw new StatusError(StatusError.BadRequest, `Invalid ${name}`); + } + return date; +} + +export const POST = createSmartRouteHandler({ + metadata: { + summary: "Get page heatmap data", + description: "Returns click heatmap data for the current browser origin when authorized by a short-lived heatmap token.", + tags: ["Analytics"], + hidden: true, + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + user: adaptSchema.optional(), + }).defined(), + body: yupObject({ + heatmap_token: yupString().defined(), + origin: yupString().defined(), + route_path: yupString().optional(), + route_regex: yupString().optional(), + url_pattern: yupString().optional(), + user_id: yupString().optional(), + replay_id: yupString().optional(), + device: yupString().oneOf(["tv", "widescreen", "desktop", "laptop", "tablet", "mobile"]).optional(), + viewport_width_min: yupNumber().integer().min(0).max(65535).optional(), + viewport_width_max: yupNumber().integer().min(0).max(65535).optional(), + sampling: yupNumber().min(0).max(1).optional(), + since: yupString().defined(), + until: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: AnalyticsHeatmapResponseBodySchema, + }), + handler: async ({ body }) => { + // The dashboard mint path is the feature gate for heatmap overlays. This + // public read endpoint is authorized by the short-lived origin-bound token + // below, so avoid app/user gates that can disagree with the launching + // dashboard or anonymous customer pages. + + const heatmapToken = await verifyAnalyticsHeatmapToken({ + token: body.heatmap_token, + origin: body.origin, + }); + + const since = parseBoundedDateTime(body.since, "since"); + const until = parseBoundedDateTime(body.until, "until"); + if (until.getTime() <= since.getTime()) { + throw new StatusError(StatusError.BadRequest, "until must be after since"); + } + if (until.getTime() - since.getTime() > MAX_WINDOW_DAYS * ONE_DAY_MS) { + throw new StatusError(StatusError.BadRequest, `Heatmap window cannot exceed ${MAX_WINDOW_DAYS} days`); + } + + const deviceBucket = getDeviceViewportBucket(body.device); + const viewportMin = body.viewport_width_min ?? deviceBucket?.min; + const viewportMax = body.viewport_width_max ?? deviceBucket?.max; + const urlPatternLike = buildClickmapUrlLikePattern(body.url_pattern); + const samplingPct = Math.max(1, Math.round(clampClickmapSampling(body.sampling) * 100)); + const samplingScale = 100 / samplingPct; + const samplingClause = samplingPct < 100 + ? "AND intHash32(toUInt32(toUnixTimestamp(event_at)) + cityHash64(coalesce(toString(user_id), ''))) % 100 < {samplingPct:UInt32}" + : ""; + const routeFilter = getClickmapRouteFilter(body.route_path, body.route_regex, urlPatternLike); + const userAndReplayFilter = getClickmapUserAndReplayFilter(body.user_id, body.replay_id); + const originFilter = getClickmapOriginFilter(); + const viewportFilter = getClickmapViewportFilter(viewportMin, viewportMax); + const systemElementFilter = getClickmapSystemElementFilter(); + const params: Record = { + projectId: heatmapToken.project_id, + branchId: heatmapToken.branch_id, + ...getClickmapOriginParams(heatmapToken.origin), + since: formatClickhouseDateTimeParam(since), + until: formatClickhouseDateTimeParam(until), + routeLimit: ROUTE_LIMIT, + elementsChainLimit: ELEMENTS_CHAIN_LIMIT, + samplingPct, + ...(body.route_path ? { routePath: body.route_path } : {}), + ...(body.route_regex ? { routeRegex: body.route_regex } : {}), + ...(urlPatternLike != null ? { urlPatternLike } : {}), + ...(body.user_id ? { userId: body.user_id } : {}), + ...(body.replay_id ? { replayId: body.replay_id } : {}), + ...(viewportMin != null ? { viewportWidthMin: viewportMin } : {}), + ...(viewportMax != null ? { viewportWidthMax: viewportMax } : {}), + }; + const sharedWhere = ` + project_id = {projectId:String} + AND branch_id = {branchId:String} + AND event_at >= {since:DateTime} + AND event_at < {until:DateTime} + ${originFilter} + ${routeFilter} + ${viewportFilter} + ${systemElementFilter} + ${samplingClause} + `; + + const client = getClickhouseAdminClientForMetrics(); + let routes: { path: string, clicks: number | string, users: number | string, replays: number | string }[]; + let selectors: { selector: string, clicks: number | string }[]; + let elements: { elements_chain: string, elements_text: string, tag_name: string, href: string | null, clicks: number | string }[]; + try { + const [routesResult, selectorsResult, elementsResult] = await Promise.all([ + client.query({ + query: ` + SELECT + path, + count() AS clicks, + uniqExactIf(assumeNotNull(user_id), user_id IS NOT NULL) AS users, + uniqExactIf(assumeNotNull(session_replay_id), session_replay_id IS NOT NULL) AS replays + FROM analytics_internal.clickmap_events + WHERE ${sharedWhere} + AND path != '' + ${userAndReplayFilter} + GROUP BY path + ORDER BY clicks DESC + LIMIT {routeLimit:UInt32} + `, + query_params: params, + format: "JSONEachRow", + }), + client.query({ + query: ` + SELECT + nullIf(selector, '') AS selector, + count() AS clicks + FROM analytics_internal.clickmap_events + WHERE ${sharedWhere} + AND selector != '' + ${userAndReplayFilter} + GROUP BY selector + ORDER BY clicks DESC + LIMIT {routeLimit:UInt32} + `, + query_params: params, + format: "JSONEachRow", + }), + client.query({ + query: ` + SELECT + elements_chain, + any(elements_text) AS elements_text, + any(tag_name) AS tag_name, + any(href) AS href, + count() AS clicks + FROM analytics_internal.clickmap_events + WHERE ${sharedWhere} + AND elements_chain != '' + ${userAndReplayFilter} + GROUP BY elements_chain + ORDER BY clicks DESC + LIMIT {elementsChainLimit:UInt32} + `, + query_params: params, + format: "JSONEachRow", + }), + ]); + + routes = await routesResult.json(); + selectors = await selectorsResult.json(); + elements = await elementsResult.json(); + } catch (error) { + if (!(error instanceof ClickHouseError)) { + throw error; + } + if (body.route_regex != null && body.route_regex !== "") { + throw new StatusError(StatusError.BadRequest, "Invalid route regex"); + } + captureError("analytics-heatmap-clickhouse-fallback", new HexclaveAssertionError( + "Failed to load analytics heatmap due to ClickHouse query failure.", + { cause: error, projectId: heatmapToken.project_id, branchId: heatmapToken.branch_id }, + )); + throw new StatusError(StatusError.ServiceUnavailable, "Analytics heatmap is temporarily unavailable."); + } + + const scaleCount = (value: number | string) => Math.round(Number(value) * samplingScale); + const responseBody: AnalyticsHeatmapResponse = { + kind: "session_replay_clicks", + cells: [], + sampling: samplingPct / 100, + routes: routes.map((row) => ({ path: row.path, clicks: scaleCount(row.clicks), users: scaleCount(row.users), replays: scaleCount(row.replays) })), + users: [], + replays: [], + selectors: selectors.map((row) => ({ selector: row.selector, clicks: scaleCount(row.clicks) })), + elements: elements.map((row) => ({ + elements_chain: row.elements_chain, + elements_text: row.elements_text, + tag_name: row.tag_name, + href: row.href, + clicks: scaleCount(row.clicks), + })), + }; + + return { + statusCode: 200, + bodyType: "json", + body: responseBody, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/analytics/heatmap-token/route.ts b/apps/backend/src/app/api/latest/internal/analytics/heatmap-token/route.ts new file mode 100644 index 0000000000..ed66f77b54 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/analytics/heatmap-token/route.ts @@ -0,0 +1,34 @@ +import { createAnalyticsHeatmapToken } from "@/lib/analytics-heatmap-tokens"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { AnalyticsHeatmapTokenResponseBodySchema } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +export const POST = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + body: yupObject({ + origin: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: AnalyticsHeatmapTokenResponseBodySchema, + }), + handler: async ({ auth, body }) => { + const token = await createAnalyticsHeatmapToken({ tenancy: auth.tenancy, origin: body.origin }); + return { + statusCode: 200, + bodyType: "json", + body: { + token: token.token, + origin: token.origin, + expires_at_millis: token.expiresAtMillis, + }, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.test.ts b/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.test.ts new file mode 100644 index 0000000000..6f36f2115b --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { + buildClickmapUrlLikePattern, + buildHourOfWeekHeatmapCells, + clampClickmapSampling, + getClickmapOriginFilter, + getClickmapOriginParams, + getClickmapReplayFilter, + getClickmapRouteFilter, + getClickmapSystemElementFilter, + getClickmapUserAndReplayFilter, + getClickmapUserFilter, + getClickmapViewportFilter, + getDeviceViewportBucket, +} from "./route"; + +describe("analytics heatmap helpers", () => { + it("pads sparse hour-of-week rows into a complete 7x24 grid", () => { + const cells = buildHourOfWeekHeatmapCells([ + { 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 = buildHourOfWeekHeatmapCells([ + { 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(getClickmapUserFilter("user-123")).toMatchInlineSnapshot(`"AND user_id = {userId:Nullable(String)}"`); + expect(getClickmapUserFilter(undefined)).toMatchInlineSnapshot(`""`); + expect(getClickmapReplayFilter("replay-123")).toMatchInlineSnapshot(`"AND session_replay_id = {replayId:Nullable(String)}"`); + expect(getClickmapReplayFilter(undefined)).toMatchInlineSnapshot(`""`); + 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)}"`); + }); + + 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); + }); +}); diff --git a/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts b/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts new file mode 100644 index 0000000000..611827d914 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts @@ -0,0 +1,428 @@ +import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse"; +import { getPrismaClientForTenancy } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { AnalyticsHeatmapResponseBodySchema } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { ClickHouseError } from "@clickhouse/client"; +import { HexclaveAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; +import { userFullInclude, userPrismaToCrud } from "../../../users/crud"; + +const MAX_TEAM_MEMBER_IDS = 500; +const MAX_WINDOW_DAYS = 92; +const ROUTE_LIMIT = 50; +const LINKED_LIMIT = 25; +const ELEMENTS_CHAIN_LIMIT = 100; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +function formatClickhouseDateTimeParam(date: Date): string { + return date.toISOString().slice(0, 19); +} + +function parseBoundedDateTime(value: string, name: string): Date { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) { + throw new StatusError(StatusError.BadRequest, `Invalid ${name}`); + } + return date; +} + +// Device class buckets — kept as a back-compat shim for callers that still pass +// `device`. Internally collapsed into viewport_width_min/max so the MV order key +// (which leads with viewport_width) does the work instead of a multiIf scan. +const DEVICE_WIDTH_BUCKETS: Record = { + tv: { min: 1920, max: 65535 }, + widescreen: { min: 1440, max: 1919 }, + desktop: { min: 1200, max: 1439 }, + laptop: { min: 1024, max: 1199 }, + tablet: { min: 768, max: 1023 }, + mobile: { min: 0, max: 767 }, +}; + +export function getDeviceViewportBucket(device: string | undefined): { min: number, max: number } | null { + if (device == null || device === "") return null; + return DEVICE_WIDTH_BUCKETS[device] ?? null; +} + +// Translate a PostHog-style URL pattern with `*` wildcards into a SQL LIKE +// pattern, escaping the underlying `_` / `%` / `\` so they're treated literally. +// Empty string disables the filter. +export function buildClickmapUrlLikePattern(urlPattern: string | undefined): string | null { + if (urlPattern == null || urlPattern === "") return null; + const escaped = urlPattern.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); + return escaped.replace(/\*/g, "%"); +} + +export function getClickmapRouteFilter(routePath: string | undefined, routeRegex: string | undefined, urlPatternLike: string | null): string { + if (routeRegex != null && routeRegex !== "") { + return "AND match(path, {routeRegex:String})"; + } + if (urlPatternLike != null) { + return "AND path LIKE {urlPatternLike:String}"; + } + if (routePath != null && routePath !== "") { + return "AND path = {routePath:String}"; + } + return ""; +} + +export function getClickmapViewportFilter(min: number | undefined, max: number | undefined): string { + const clauses: string[] = []; + if (min != null) clauses.push("AND viewport_width >= {viewportWidthMin:UInt32}"); + if (max != null) clauses.push("AND viewport_width <= {viewportWidthMax:UInt32}"); + return clauses.join(" "); +} + +export function getClickmapUserFilter(userId: string | undefined): string { + if (userId == null || userId === "") return ""; + return "AND user_id = {userId:Nullable(String)}"; +} + +export function getClickmapReplayFilter(replayId: string | undefined): string { + if (replayId == null || replayId === "") return ""; + return "AND session_replay_id = {replayId:Nullable(String)}"; +} + +export function getClickmapUserAndReplayFilter(userId: string | undefined, replayId: string | undefined): string { + return [getClickmapUserFilter(userId), getClickmapReplayFilter(replayId)].filter((filter) => filter !== "").join(" "); +} + +export function getClickmapOriginFilter(): string { + return "AND (url = {origin:String} OR startsWith(url, {originSlashPrefix:String}) OR startsWith(url, {originQueryPrefix:String}) OR startsWith(url, {originHashPrefix:String}))"; +} + +export function getClickmapOriginParams(origin: string): { + origin: string, + originSlashPrefix: string, + originQueryPrefix: string, + originHashPrefix: string, +} { + return { + origin, + originSlashPrefix: `${origin}/`, + originQueryPrefix: `${origin}?`, + originHashPrefix: `${origin}#`, + }; +} + +export function getClickmapSystemElementFilter(): string { + return [ + "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", + ].join(" "); +} + +export function clampClickmapSampling(value: number | undefined): number { + if (value == null || !Number.isFinite(value)) return 1; + if (value <= 0) return 0.01; + if (value > 1) return 1; + return value; +} + +export function buildHourOfWeekHeatmapCells(rows: { weekday: number | string, hour: number | string, value: number | string }[]) { + const byCell = new Map(); + for (const row of rows) { + const weekday = Number(row.weekday); + const hour = Number(row.hour); + if (!Number.isInteger(weekday) || weekday < 1 || weekday > 7) continue; + if (!Number.isInteger(hour) || hour < 0 || hour > 23) continue; + byCell.set(`${weekday}:${hour}`, Number(row.value)); + } + + const cells: { weekday: number, hour: number, value: number }[] = []; + for (let weekday = 1; weekday <= 7; weekday += 1) { + for (let hour = 0; hour < 24; hour += 1) { + cells.push({ weekday, hour, value: byCell.get(`${weekday}:${hour}`) ?? 0 }); + } + } + return cells; +} + +export const POST = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }), + body: yupObject({ + kind: yupString().oneOf(["team_user_hour_of_week", "session_replay_clicks"]).defined(), + member_user_ids: yupArray(yupString().defined()).optional().default([]).max(MAX_TEAM_MEMBER_IDS), + route_path: yupString().optional(), + route_regex: yupString().optional(), + url_pattern: yupString().optional(), + user_id: yupString().optional(), + replay_id: yupString().optional(), + device: yupString().oneOf(["tv", "widescreen", "desktop", "laptop", "tablet", "mobile"]).optional(), + viewport_width_min: yupNumber().integer().min(0).max(65535).optional(), + viewport_width_max: yupNumber().integer().min(0).max(65535).optional(), + sampling: yupNumber().min(0).max(1).optional(), + since: yupString().defined(), + until: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: AnalyticsHeatmapResponseBodySchema, + }), + handler: async ({ auth, body }) => { + const since = parseBoundedDateTime(body.since, "since"); + const until = parseBoundedDateTime(body.until, "until"); + if (until.getTime() <= since.getTime()) { + throw new StatusError(StatusError.BadRequest, "until must be after since"); + } + if (until.getTime() - since.getTime() > MAX_WINDOW_DAYS * ONE_DAY_MS) { + throw new StatusError(StatusError.BadRequest, `Heatmap window cannot exceed ${MAX_WINDOW_DAYS} days`); + } + + const client = getClickhouseAdminClientForMetrics(); + + try { + if (body.kind === "session_replay_clicks") { + const deviceBucket = getDeviceViewportBucket(body.device); + // Explicit min/max win over the legacy device bucket so callers can + // narrow further (e.g. mobile + viewport_width_min=400). + const viewportMin = body.viewport_width_min ?? deviceBucket?.min; + const viewportMax = body.viewport_width_max ?? deviceBucket?.max; + const urlPatternLike = buildClickmapUrlLikePattern(body.url_pattern); + const samplingPct = Math.max(1, Math.round(clampClickmapSampling(body.sampling) * 100)); + const samplingScale = 100 / samplingPct; + const samplingClause = samplingPct < 100 + ? "AND intHash32(toUInt32(toUnixTimestamp(event_at)) + cityHash64(coalesce(toString(user_id), ''))) % 100 < {samplingPct:UInt32}" + : ""; + const routeFilter = getClickmapRouteFilter(body.route_path, body.route_regex, urlPatternLike); + const userAndReplayFilter = getClickmapUserAndReplayFilter(body.user_id, body.replay_id); + const viewportFilter = getClickmapViewportFilter(viewportMin, viewportMax); + const systemElementFilter = getClickmapSystemElementFilter(); + const params: Record = { + projectId: auth.tenancy.project.id, + branchId: auth.tenancy.branchId, + since: formatClickhouseDateTimeParam(since), + until: formatClickhouseDateTimeParam(until), + linkedLimit: LINKED_LIMIT, + routeLimit: ROUTE_LIMIT, + elementsChainLimit: ELEMENTS_CHAIN_LIMIT, + samplingPct, + ...(body.route_path ? { routePath: body.route_path } : {}), + ...(body.route_regex ? { routeRegex: body.route_regex } : {}), + ...(urlPatternLike != null ? { urlPatternLike } : {}), + ...(body.user_id ? { userId: body.user_id } : {}), + ...(body.replay_id ? { replayId: body.replay_id } : {}), + ...(viewportMin != null ? { viewportWidthMin: viewportMin } : {}), + ...(viewportMax != null ? { viewportWidthMax: viewportMax } : {}), + }; + const sharedWhere = ` + project_id = {projectId:String} + AND branch_id = {branchId:String} + AND event_at >= {since:DateTime} + AND event_at < {until:DateTime} + ${routeFilter} + ${viewportFilter} + ${systemElementFilter} + ${samplingClause} + `; + const [routesResult, usersResult, replaysResult, selectorsResult, elementsResult] = await Promise.all([ + client.query({ + query: ` + SELECT + path, + count() AS clicks, + uniqExactIf(assumeNotNull(user_id), user_id IS NOT NULL) AS users, + uniqExactIf(assumeNotNull(session_replay_id), session_replay_id IS NOT NULL) AS replays + FROM analytics_internal.clickmap_events + WHERE ${sharedWhere} + AND path != '' + ${userAndReplayFilter} + GROUP BY path + ORDER BY clicks DESC + LIMIT {routeLimit:UInt32} + `, + query_params: params, + format: "JSONEachRow", + }), + client.query({ + query: ` + SELECT + assumeNotNull(user_id) AS id, + count() AS clicks, + uniqExactIf(assumeNotNull(session_replay_id), session_replay_id IS NOT NULL) AS replays, + toUnixTimestamp64Milli(max(event_at)) AS last_event_at_millis + FROM analytics_internal.clickmap_events + WHERE ${sharedWhere} + AND user_id IS NOT NULL + ${userAndReplayFilter} + GROUP BY id + ORDER BY last_event_at_millis DESC, clicks DESC + LIMIT {linkedLimit:UInt32} + `, + query_params: params, + format: "JSONEachRow", + }), + client.query({ + query: ` + SELECT + assumeNotNull(session_replay_id) AS id, + any(user_id) AS linked_user_id, + nullIf(any(path), '') AS route_path, + toInt32(any(viewport_width)) AS viewport_width, + toInt32(any(viewport_height)) AS viewport_height, + count() AS clicks, + toUnixTimestamp64Milli(max(event_at)) AS last_event_at_millis + FROM analytics_internal.clickmap_events + WHERE ${sharedWhere} + AND session_replay_id IS NOT NULL + ${userAndReplayFilter} + GROUP BY id + ORDER BY clicks DESC + LIMIT {linkedLimit:UInt32} + `, + query_params: params, + format: "JSONEachRow", + }), + client.query({ + query: ` + SELECT + nullIf(selector, '') AS selector, + count() AS clicks + FROM analytics_internal.clickmap_events + WHERE ${sharedWhere} + AND selector != '' + ${userAndReplayFilter} + GROUP BY selector + ORDER BY clicks DESC + LIMIT {linkedLimit:UInt32} + `, + query_params: params, + format: "JSONEachRow", + }), + client.query({ + query: ` + SELECT + elements_chain, + any(elements_text) AS elements_text, + any(tag_name) AS tag_name, + any(href) AS href, + count() AS clicks + FROM analytics_internal.clickmap_events + WHERE ${sharedWhere} + AND elements_chain != '' + ${userAndReplayFilter} + GROUP BY elements_chain + ORDER BY clicks DESC + LIMIT {elementsChainLimit:UInt32} + `, + query_params: params, + format: "JSONEachRow", + }), + ]); + const routes: { path: string, clicks: number | string, users: number | string, replays: number | string }[] = await routesResult.json(); + const users: { id: string, clicks: number | string, replays: number | string, last_event_at_millis: number | string }[] = await usersResult.json(); + const replays: { 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 }[] = await replaysResult.json(); + const selectors: { selector: string, clicks: number | string }[] = await selectorsResult.json(); + const elements: { elements_chain: string, elements_text: string, tag_name: string, href: string | null, clicks: number | string }[] = await elementsResult.json(); + const userIds = users.map((row) => row.id); + const prisma = await getPrismaClientForTenancy(auth.tenancy); + const dbUsers = userIds.length === 0 ? [] : await prisma.$replica().projectUser.findMany({ + where: { + tenancyId: auth.tenancy.id, + projectUserId: { in: userIds }, + }, + include: userFullInclude, + }); + const userProfilesById = new Map(dbUsers.map((user) => { + const crud = userPrismaToCrud(user, auth.tenancy.config); + return [crud.id, { + display_name: crud.display_name, + primary_email: crud.primary_email, + profile_image_url: crud.profile_image_url, + }]; + })); + + const scaleCount = (value: number | string) => Math.round(Number(value) * samplingScale); + return { + statusCode: 200, + bodyType: "json", + body: { + kind: body.kind, + cells: [], + sampling: samplingPct / 100, + routes: routes.map((row) => ({ path: row.path, clicks: scaleCount(row.clicks), users: scaleCount(row.users), replays: scaleCount(row.replays) })), + users: users.map((row) => { + const profile = userProfilesById.get(row.id); + return { + id: row.id, + display_name: profile?.display_name ?? null, + primary_email: profile?.primary_email ?? null, + profile_image_url: profile?.profile_image_url ?? null, + clicks: scaleCount(row.clicks), + replays: scaleCount(row.replays), + last_event_at_millis: Number(row.last_event_at_millis), + }; + }), + replays: replays.map((row) => ({ + id: row.id, + 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), + })), + selectors: selectors.map((row) => ({ selector: row.selector, clicks: scaleCount(row.clicks) })), + elements: elements.map((row) => ({ + elements_chain: row.elements_chain, + elements_text: row.elements_text, + tag_name: row.tag_name, + href: row.href, + clicks: scaleCount(row.clicks), + })), + }, + }; + } + + if (body.member_user_ids.length === 0) { + return { statusCode: 200, bodyType: "json", body: { kind: body.kind, cells: buildHourOfWeekHeatmapCells([]), sampling: 1, routes: [], users: [], replays: [], selectors: [], elements: [] } }; + } + + const result = await client.query({ + query: ` + SELECT toDayOfWeek(event_at) AS weekday, toHour(event_at) AS hour, uniqExact(assumeNotNull(user_id)) AS value + FROM analytics_internal.events + WHERE project_id = {projectId:String} + AND branch_id = {branchId:String} + AND user_id IN {memberUserIds:Array(String)} + AND event_at >= {since:DateTime} + AND event_at < {until:DateTime} + GROUP BY weekday, hour + ORDER BY weekday ASC, hour ASC + `, + query_params: { + projectId: auth.tenancy.project.id, + branchId: auth.tenancy.branchId, + memberUserIds: body.member_user_ids, + since: formatClickhouseDateTimeParam(since), + until: formatClickhouseDateTimeParam(until), + }, + format: "JSONEachRow", + }); + const rows: { weekday: number | string, hour: number | string, value: number | string }[] = await result.json(); + return { statusCode: 200, bodyType: "json", body: { kind: body.kind, cells: buildHourOfWeekHeatmapCells(rows), sampling: 1, routes: [], users: [], replays: [], selectors: [], elements: [] } }; + } catch (error) { + if (!(error instanceof ClickHouseError)) { + throw error; + } + if (body.kind === "session_replay_clicks" && body.route_regex != null && body.route_regex !== "") { + throw new StatusError(StatusError.BadRequest, "Invalid route regex"); + } + captureError("internal-analytics-heatmap-clickhouse-fallback", new HexclaveAssertionError( + "Failed to load analytics heatmap due to ClickHouse query failure.", + { cause: error, projectId: auth.tenancy.project.id, branchId: auth.tenancy.branchId, kind: body.kind }, + )); + throw new StatusError(StatusError.ServiceUnavailable, "Analytics heatmap is temporarily unavailable."); + } + }, +}); diff --git a/apps/backend/src/lib/analytics-heatmap-tokens.test.ts b/apps/backend/src/lib/analytics-heatmap-tokens.test.ts new file mode 100644 index 0000000000..2b4aa6f68e --- /dev/null +++ b/apps/backend/src/lib/analytics-heatmap-tokens.test.ts @@ -0,0 +1,50 @@ +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { signJWT } from "@stackframe/stack-shared/dist/utils/jwt"; +import { describe, expect, it } from "vitest"; +import { normalizeAnalyticsHeatmapOrigin, verifyAnalyticsHeatmapToken } from "./analytics-heatmap-tokens"; + +describe("analytics heatmap token helpers", () => { + it("normalizes a trusted-domain URL to its origin", () => { + expect(normalizeAnalyticsHeatmapOrigin("https://example.com/dashboard?x=1")).toMatchInlineSnapshot(`"https://example.com"`); + }); + + it("rejects non-HTTP origins", () => { + expect(() => normalizeAnalyticsHeatmapOrigin("javascript:alert(1)")).toThrow(StatusError); + }); + + it("returns the project encoded in a valid heatmap token", async () => { + const token = await signJWT({ + issuer: "hexclave:analytics:heatmap", + audience: "hexclave:analytics:heatmap-overlay", + expirationTime: "24h", + payload: { + kind: "analytics_heatmap_overlay", + scope: "heatmap:read", + project_id: "internal", + branch_id: "main", + origin: "http://localhost:8101", + }, + }); + + const payload = await verifyAnalyticsHeatmapToken({ + token, + origin: "http://localhost:8101/projects/internal/analytics/heatmaps", + }); + + expect({ + kind: payload.kind, + scope: payload.scope, + project_id: payload.project_id, + branch_id: payload.branch_id, + origin: payload.origin, + }).toMatchInlineSnapshot(` + { + "branch_id": "main", + "kind": "analytics_heatmap_overlay", + "origin": "http://localhost:8101", + "project_id": "internal", + "scope": "heatmap:read", + } + `); + }); +}); diff --git a/apps/backend/src/lib/analytics-heatmap-tokens.ts b/apps/backend/src/lib/analytics-heatmap-tokens.ts new file mode 100644 index 0000000000..21a6e9a897 --- /dev/null +++ b/apps/backend/src/lib/analytics-heatmap-tokens.ts @@ -0,0 +1,94 @@ +import type { Tenancy } from "@/lib/tenancies"; +import { validateRedirectUrl } from "@/lib/redirect-urls"; +import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { signJWT, verifyJWT } from "@stackframe/stack-shared/dist/utils/jwt"; +import { yupValidate } from "@stackframe/stack-shared/dist/schema-fields"; + +const HEATMAP_TOKEN_ISSUER = "hexclave:analytics:heatmap"; +const HEATMAP_TOKEN_AUDIENCE = "hexclave:analytics:heatmap-overlay"; +const HEATMAP_TOKEN_KIND = "analytics_heatmap_overlay"; +const HEATMAP_TOKEN_SCOPE = "heatmap:read"; +export const HEATMAP_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; + +const AnalyticsHeatmapTokenPayloadSchema = yupObject({ + kind: yupString().oneOf([HEATMAP_TOKEN_KIND]).defined(), + scope: yupString().oneOf([HEATMAP_TOKEN_SCOPE]).defined(), + project_id: yupString().defined(), + branch_id: yupString().defined(), + origin: yupString().defined(), +}).defined(); + +export type AnalyticsHeatmapTokenPayload = { + kind: typeof HEATMAP_TOKEN_KIND, + scope: typeof HEATMAP_TOKEN_SCOPE, + project_id: string, + branch_id: string, + origin: string, +}; + +export function normalizeAnalyticsHeatmapOrigin(origin: string): string { + let url: URL; + try { + url = new URL(origin); + } catch { + throw new StatusError(StatusError.BadRequest, "Invalid heatmap origin"); + } + + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new StatusError(StatusError.BadRequest, "Heatmap origin must be an HTTP(S) origin"); + } + + return url.origin; +} + +export function validateAnalyticsHeatmapOrigin(tenancy: Tenancy, origin: string): string { + const normalizedOrigin = normalizeAnalyticsHeatmapOrigin(origin); + if (!validateRedirectUrl(`${normalizedOrigin}/`, tenancy)) { + throw new StatusError(StatusError.Forbidden, "Heatmap origin is not a trusted domain for this project"); + } + return normalizedOrigin; +} + +export async function createAnalyticsHeatmapToken(options: { + tenancy: Tenancy, + origin: string, +}): Promise<{ token: string, origin: string, expiresAtMillis: number }> { + const origin = validateAnalyticsHeatmapOrigin(options.tenancy, options.origin); + const expiresAtMillis = Date.now() + HEATMAP_TOKEN_TTL_MS; + const token = await signJWT({ + issuer: HEATMAP_TOKEN_ISSUER, + audience: HEATMAP_TOKEN_AUDIENCE, + expirationTime: "24h", + payload: { + kind: HEATMAP_TOKEN_KIND, + scope: HEATMAP_TOKEN_SCOPE, + project_id: options.tenancy.project.id, + branch_id: options.tenancy.branchId, + origin, + } satisfies AnalyticsHeatmapTokenPayload, + }); + return { token, origin, expiresAtMillis }; +} + +export async function verifyAnalyticsHeatmapToken(options: { + token: string, + origin: string, +}): Promise { + const origin = normalizeAnalyticsHeatmapOrigin(options.origin); + let payload: AnalyticsHeatmapTokenPayload; + try { + payload = await yupValidate( + AnalyticsHeatmapTokenPayloadSchema, + await verifyJWT({ allowedIssuers: [HEATMAP_TOKEN_ISSUER], jwt: options.token }), + { abortEarly: false }, + ); + } catch { + throw new StatusError(StatusError.Unauthorized, "Invalid or expired heatmap token"); + } + + if (payload.origin !== origin) { + throw new StatusError(StatusError.Forbidden, "Heatmap token origin does not match this page"); + } + return payload; +} diff --git a/apps/backend/src/route-handlers/smart-route-handler.tsx b/apps/backend/src/route-handlers/smart-route-handler.tsx index fc1e0146bd..acc140964b 100644 --- a/apps/backend/src/route-handlers/smart-route-handler.tsx +++ b/apps/backend/src/route-handlers/smart-route-handler.tsx @@ -105,6 +105,8 @@ export function handleApiRequest(handler: (req: NextRequest, options: any, reque // request duration warning const allowedLongRequestPaths = [ "/api/latest/internal/email-queue-step", + "/api/latest/analytics/heatmap", + "/api/latest/internal/analytics/heatmap", "/api/latest/internal/analytics/query", "/api/latest/ai/query/stream", "/api/latest/ai/query/generate", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx new file mode 100644 index 0000000000..6d4bbfde6c --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx @@ -0,0 +1,485 @@ +"use client"; + +import { AppEnabledGuard } from "../../app-enabled-guard"; +import { PageLayout } from "../../page-layout"; +import { useAdminApp } from "../../use-admin-app"; +import { + Alert, + Button, + CopyField, + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Spinner, + toast, + Typography, +} from "@/components/ui"; +import { DesignAnalyticsCard } from "@/components/design-components/analytics-card"; +import { + createDefaultDataGridState, + DataGrid, + useDataSource, + type DataGridColumnDef, +} from "@stackframe/dashboard-ui-components"; +import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; +import { ArrowRight, GlobeHemisphereWest } from "@phosphor-icons/react"; +import { useEffect, useMemo, useState } from "react"; + +type HeatmapOrigin = { + id: string, + origin: string, +}; + +type RangeKey = "24h" | "7d" | "30d"; +type DeviceFilterKey = "all" | AnalyticsHeatmapDevice; + +const RANGE_MS: Record = { + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000, + "30d": 30 * 24 * 60 * 60 * 1000, +}; + +const RANGE_OPTIONS: Array<{ value: RangeKey, label: string }> = [ + { value: "24h", label: "Last 24h" }, + { value: "7d", label: "Last 7 days" }, + { value: "30d", label: "Last 30 days" }, +]; + +const DEVICE_OPTIONS: Array<{ value: DeviceFilterKey, label: string }> = [ + { value: "all", label: "All viewports" }, + { value: "mobile", label: "Mobile" }, + { value: "tablet", label: "Tablet" }, + { value: "laptop", label: "Laptop" }, + { value: "desktop", label: "Desktop" }, + { value: "widescreen", label: "Widescreen" }, + { value: "tv", label: "TV" }, +]; + +function truncateMiddle(value: string, max: number): string { + if (value.length <= max) return value; + const half = Math.floor((max - 1) / 2); + return `${value.slice(0, half)}…${value.slice(value.length - half)}`; +} + +type TopElementRow = AnalyticsHeatmapResponse["elements"][number]; + +const getTopElementRowId = (row: TopElementRow): string => row.elements_chain; + +// Stable column definitions — defined at module scope so the grid instance +// is preserved across renders (required by DataGrid). +const TOP_ELEMENT_COLUMNS: DataGridColumnDef[] = [ + { + id: "clicks", + header: "Clicks", + accessor: "clicks", + type: "number", + width: 96, + align: "right", + sortable: true, + renderCell: ({ row }) => ( + + {row.clicks} + + ), + }, + { + id: "element", + header: "Element", + accessor: "elements_chain", + flex: 1, + minWidth: 240, + sortable: false, + cellOverflow: "wrap", + renderCell: ({ row }) => { + const text = row.elements_text.trim(); + const fallbackLabel = text !== "" ? text : (row.href ?? ""); + return ( +
+
+ {row.tag_name} + {fallbackLabel !== "" && ( + {fallbackLabel} + )} +
+
+ {truncateMiddle(row.elements_chain, 140)} +
+
+ ); + }, + }, +]; + +function TopElementsPreview(props: { + adminApp: ReturnType, +}) { + const { adminApp } = props; + const [range, setRange] = useState("7d"); + const [device, setDevice] = useState("all"); + const [urlPattern, setUrlPattern] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + useEffect(() => { + let cancelled = false; + const handle = setTimeout(() => { + const until = new Date(); + const since = new Date(until.getTime() - RANGE_MS[range]); + const options: Parameters[0] = { + kind: "session_replay_clicks", + since: since.toISOString(), + until: until.toISOString(), + sampling: 1, + }; + const trimmedPattern = urlPattern.trim(); + if (trimmedPattern !== "") { + options.url_pattern = trimmedPattern; + } + if (device !== "all") { + options.device = device; + } + setLoading(true); + setError(null); + adminApp.getAnalyticsHeatmap(options) + .then((response) => { + if (cancelled) return; + setData(response); + }) + .catch((err: unknown) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : "Failed to load top elements."); + setData(null); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + }, 250); + return () => { + cancelled = true; + clearTimeout(handle); + }; + }, [adminApp, range, device, urlPattern]); + + const elements = useMemo(() => { + if (data == null) return []; + // Dedupe by elements_chain so row ids stay unique for virtualization, + // then sort by clicks descending as the default order. + const byChain = new Map(); + for (const element of data.elements) { + const existing = byChain.get(element.elements_chain); + if (existing == null || element.clicks > existing.clicks) { + byChain.set(element.elements_chain, element); + } + } + return Array.from(byChain.values()).sort((a, b) => b.clicks - a.clicks); + }, [data]); + + const [gridState, setGridState] = useState(() => createDefaultDataGridState(TOP_ELEMENT_COLUMNS)); + const gridData = useDataSource({ + data: elements, + columns: TOP_ELEMENT_COLUMNS, + getRowId: getTopElementRowId, + sorting: gridState.sorting, + quickSearch: gridState.quickSearch, + // Single full page — the grid virtualizes the rows and scrolls them + // internally, so the whole (deduped) set stays available without paging. + pagination: { pageIndex: 0, pageSize: Math.max(elements.length, 1) }, + paginationMode: "client", + }); + + return ( +
+
+
+ Top elements + + Most-clicked elements across replays for the selected filters. + +
+ {loading && ( +
+ + Loading top elements… +
+ )} +
+ {error != null ? ( + {error} + ) : ( + ( +
+ + + setUrlPattern(event.target.value)} + placeholder="/products/*" + className="h-8 w-full text-xs sm:ml-auto sm:w-[220px]" + /> +
+ )} + footer={false} + fillHeight={false} + rowHeight="auto" + estimatedRowHeight={56} + overscan={8} + paginationMode="infinite" + emptyState={ +
+ No clicks captured in this window. +
+ } + /> + )} +
+ ); +} + +function normalizeOrigin(baseUrl: string): string | null { + try { + return new URL(baseUrl).origin; + } catch { + return null; + } +} + +const HEATMAP_TOKEN_STORAGE_KEY = "hexclave-heatmap-overlay-token"; +const HEATMAP_ORIGIN_STORAGE_KEY = "hexclave-heatmap-overlay-origin"; +const HEATMAP_PROJECT_STORAGE_KEY = "hexclave-heatmap-overlay-project-id"; + +function getProjectHeatmapTokenStorageKey(projectId: string): string { + return `${HEATMAP_TOKEN_STORAGE_KEY}:${projectId}`; +} + +function getProjectHeatmapOriginStorageKey(projectId: string): string { + return `${HEATMAP_ORIGIN_STORAGE_KEY}:${projectId}`; +} + +function createConsoleSnippet(token: string, origin: string, projectId: string): string { + return [ + `sessionStorage.setItem(${JSON.stringify(HEATMAP_PROJECT_STORAGE_KEY)}, ${JSON.stringify(projectId)});`, + `sessionStorage.setItem(${JSON.stringify(getProjectHeatmapOriginStorageKey(projectId))}, ${JSON.stringify(origin)});`, + `sessionStorage.setItem(${JSON.stringify(getProjectHeatmapTokenStorageKey(projectId))}, ${JSON.stringify(token)});`, + `sessionStorage.setItem(${JSON.stringify(HEATMAP_ORIGIN_STORAGE_KEY)}, ${JSON.stringify(origin)});`, + `sessionStorage.setItem(${JSON.stringify(HEATMAP_TOKEN_STORAGE_KEY)}, ${JSON.stringify(token)});`, + `window.dispatchEvent(new Event("hexclave:heatmap-token-updated"));`, + `console.info("Hexclave heatmap toolbar enabled for this tab.");`, + ].join("\n"); +} + +function installHeatmapTokenForCurrentOrigin(token: AnalyticsHeatmapTokenResponse, projectId: string): boolean { + if (token.origin !== window.location.origin) { + return false; + } + try { + window.sessionStorage.setItem(HEATMAP_PROJECT_STORAGE_KEY, projectId); + window.sessionStorage.setItem(getProjectHeatmapOriginStorageKey(projectId), token.origin); + window.sessionStorage.setItem(getProjectHeatmapTokenStorageKey(projectId), token.token); + window.sessionStorage.setItem(HEATMAP_ORIGIN_STORAGE_KEY, token.origin); + window.sessionStorage.setItem(HEATMAP_TOKEN_STORAGE_KEY, token.token); + window.dispatchEvent(new Event("hexclave:heatmap-token-updated")); + return true; + } catch { + window.alert("Could not enable the heatmap toolbar in this tab. Copy the snippet and paste it in the console instead."); + return false; + } +} + +function HeatmapTokenDialog(props: { + origin: HeatmapOrigin | null, + projectId: string, + token: AnalyticsHeatmapTokenResponse | null, + open: boolean, + onOpenChange: (open: boolean) => void, +}) { + const snippet = props.token == null ? "" : createConsoleSnippet(props.token.token, props.token.origin, props.projectId); + + return ( + + + + Enable heatmap toolbar + + Paste this in the console on {props.origin?.origin ?? "the selected site"}. The token expires in 24 hours. + + + + {props.token == null ? ( + Creating heatmap token... + ) : ( + <> + + + The site will use normal client authentication plus this origin-bound heatmap token to fetch aggregate clickmap data. + + + )} + + + + + + + ); +} + +export default function PageClient() { + const adminApp = useAdminApp(); + const project = adminApp.useProject(); + const config = project.useConfig(); + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedOrigin, setSelectedOrigin] = useState(null); + const [token, setToken] = useState(null); + const [customOrigin, setCustomOrigin] = useState("http://localhost:8101"); + + const origins = useMemo(() => { + const byOrigin = new Map(); + for (const [id, domain] of typedEntries(config.domains.trustedDomains)) { + if (domain.baseUrl == null) { + continue; + } + const origin = normalizeOrigin(domain.baseUrl); + if (origin == null) { + continue; + } + byOrigin.set(origin, { id, origin }); + } + return Array.from(byOrigin.values()).sort((a, b) => stringCompare(a.origin, b.origin)); + }, [config.domains.trustedDomains]); + + async function showHeatmap(origin: HeatmapOrigin) { + setSelectedOrigin(origin); + setToken(null); + setDialogOpen(true); + const created = await adminApp.createAnalyticsHeatmapToken({ origin: origin.origin }); + setToken(created); + const installedInCurrentTab = installHeatmapTokenForCurrentOrigin(created, adminApp.projectId); + try { + await navigator.clipboard.writeText(createConsoleSnippet(created.token, created.origin, adminApp.projectId)); + toast({ title: installedInCurrentTab ? "Heatmap toolbar enabled" : "Snippet copied to clipboard" }); + } catch { + // Clipboard access can be denied (e.g. lost user-gesture after the + // network round-trip); the snippet stays available to copy manually. + } + } + + return ( + + + {config.domains.allowLocalhost && ( + +
+
+ Localhost origin + + Use the exact origin shown in the browser address bar for your local site. + + setCustomOrigin(event.target.value)} placeholder="http://localhost:3000" /> +
+ +
+
+ )} + + {origins.length === 0 ? ( + + Add a trusted domain before launching a production heatmap. + + ) : ( + + {origins.map((origin) => ( +
+
+
+ +
+
+ {origin.origin} + + 24-hour overlay token, scoped to this origin + +
+
+ +
+ ))} +
+ )} + + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page.tsx new file mode 100644 index 0000000000..84cdebde14 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page.tsx @@ -0,0 +1,5 @@ +import PageClient from "./page-client"; + +export default function Page() { + return ; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx index d7bc2618f3..98d5159870 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/page-layout.tsx @@ -37,7 +37,7 @@ export function PageLayout(props: {
diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index b197e42add..866ca9430d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -188,7 +188,7 @@ function NavItem({ ); const buttonClasses = cn( - "group flex h-8 w-full items-center justify-between rounded-lg pl-3 pr-0.5 py-2 text-left text-sm font-semibold transition-all duration-150 hover:transition-none", + "group flex h-8 w-full items-center justify-between rounded-lg pl-2 pr-0.5 py-2 text-left text-sm font-semibold transition-all duration-150 hover:transition-none", isHighlighted ? (isSection ? activeSectionClasses : activeItemClasses) : inactiveClasses, "cursor-pointer" ); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/team-analytics.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/team-analytics.tsx index 68c739e03a..c8efeddb81 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/team-analytics.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/team-analytics.tsx @@ -120,18 +120,6 @@ const DAU_QUERY = ` ORDER BY day ASC `; -const HEATMAP_QUERY = ` - SELECT - toDayOfWeek(event_at) AS dow, - toHour(event_at) AS hour, - toString(uniqExact(user_id)) AS active_users - FROM events - WHERE user_id IN {memberIds:Array(String)} - AND event_at >= {since:DateTime} - AND event_at < {until:DateTime} - GROUP BY dow, hour -`; - const TOP_CONTRIBUTORS_QUERY = ` SELECT user_id, @@ -170,17 +158,6 @@ function parseDau(rows: Record[]): DauRow[] { .filter((r) => r.day.length > 0); } -function parseHeatmap(rows: Record[]): HeatmapRow[] { - const result: HeatmapRow[] = []; - for (const row of rows) { - const dow = toNumber(row.dow); - const hour = toNumber(row.hour); - if (dow < 1 || dow > 7 || hour < 0 || hour > 23) continue; - result.push({ dow, hour, active_users: toNumber(row.active_users) }); - } - return result; -} - function parseContributors(rows: Record[]): ContributorRow[] { const result: ContributorRow[] = []; for (const row of rows) { @@ -323,10 +300,11 @@ export function TeamAnalyticsSection({ team }: { team: ServerTeam }) { prev7dSince: toClickhouseDateTimeParam(prev7dSince), }), runQuery(DAU_QUERY, baseParams), - runQuery(HEATMAP_QUERY, { - memberIds, - since: toClickhouseDateTimeParam(heatmapSince), - until: toClickhouseDateTimeParam(now), + stackAdminApp.getAnalyticsHeatmap({ + kind: "team_user_hour_of_week", + member_user_ids: memberIds, + since: heatmapSince.toISOString(), + until: now.toISOString(), }), runQuery(TOP_CONTRIBUTORS_QUERY, { ...baseParams, limit: TOP_CONTRIBUTORS_LIMIT }), ]); @@ -351,7 +329,9 @@ export function TeamAnalyticsSection({ team }: { team: ServerTeam }) { data: { summary: summaryRes.status === "fulfilled" ? parseSummary(summaryRes.value.result) : emptySummary, dau: dauRes.status === "fulfilled" ? parseDau(dauRes.value.result) : [], - heatmap: heatmapRes.status === "fulfilled" ? parseHeatmap(heatmapRes.value.result) : [], + heatmap: heatmapRes.status === "fulfilled" + ? heatmapRes.value.cells.map((cell) => ({ dow: cell.weekday, hour: cell.hour, active_users: cell.value })) + : [], contributors: contributorsRes.status === "fulfilled" ? parseContributors(contributorsRes.value.result) : [], }, }); diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index 82f6c86c32..901da69eb9 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -394,6 +394,7 @@ export const ALL_APPS_FRONTEND = { navigationItems: [ { displayName: "Tables", href: "./tables" }, { displayName: "Replays", href: "../session-replays" }, + { displayName: "Heatmaps", href: "./heatmaps" }, { displayName: "Queries", href: "./queries" }, ], screenshots: [], diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index 71df733337..c19c24408b 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -6,7 +6,7 @@ import type { MoneyAmount } from "../utils/currency-constants"; import type { Json } from "../utils/json"; import { Result } from "../utils/results"; import { urlString } from "../utils/urls"; -import type { MetricsResponse, MetricsUserCounts, UserActivityResponse } from "./admin-metrics"; +import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "./admin-metrics"; import type { AnalyticsQueryOptions, AnalyticsQueryResponse } from "./crud/analytics"; import { EmailOutboxCrud } from "./crud/email-outbox"; import { InternalEmailsCrud } from "./crud/emails"; @@ -402,6 +402,48 @@ export class HexclaveAdminInterface extends HexclaveServerInterface { return (await response.json()) as UserActivityResponse; } + async getAnalyticsHeatmap(options: { + kind: "team_user_hour_of_week" | "session_replay_clicks", + member_user_ids?: string[], + route_path?: string, + route_regex?: string, + url_pattern?: string, + user_id?: string, + replay_id?: string, + device?: AnalyticsHeatmapDevice, + viewport_width_min?: number, + viewport_width_max?: number, + sampling?: number, + since: string, + until: string, + }): Promise { + const response = await this.sendAdminRequest( + "/internal/analytics/heatmap", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(options), + }, + null, + ); + return (await response.json()) as AnalyticsHeatmapResponse; + } + + async createAnalyticsHeatmapToken(options: { + origin: string, + }): Promise { + const response = await this.sendAdminRequest( + "/internal/analytics/heatmap-token", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(options), + }, + null, + ); + return (await response.json()) as AnalyticsHeatmapTokenResponse; + } + async getMetricsUserCounts(): Promise { const response = await this.sendAdminRequest( "/internal/metrics/user-counts", diff --git a/packages/stack-shared/src/interface/admin-metrics.ts b/packages/stack-shared/src/interface/admin-metrics.ts index 345cc67863..c25ef096e9 100644 --- a/packages/stack-shared/src/interface/admin-metrics.ts +++ b/packages/stack-shared/src/interface/admin-metrics.ts @@ -179,6 +179,67 @@ export const UserActivityResponseBodySchema = yupObject({ data_points: MetricsDataPointsSchema, }).defined(); +export const AnalyticsHeatmapKindSchema = yupString().oneOf(["team_user_hour_of_week", "session_replay_clicks"]).defined(); +export const AnalyticsHeatmapDeviceSchema = yupString().oneOf(["tv", "widescreen", "desktop", "laptop", "tablet", "mobile"]).defined(); + +export const AnalyticsHeatmapTokenResponseBodySchema = yupObject({ + token: yupString().defined(), + origin: yupString().defined(), + expires_at_millis: yupNumber().integer().defined(), +}).defined(); + +export const AnalyticsHeatmapCellSchema = yupObject({ + weekday: yupNumber().integer().min(1).max(7).defined(), + hour: yupNumber().integer().min(0).max(23).defined(), + value: yupNumber().integer().defined(), +}).defined(); + +export const AnalyticsHeatmapResponseBodySchema = yupObject({ + kind: AnalyticsHeatmapKindSchema, + cells: yupArray(AnalyticsHeatmapCellSchema).defined(), + // Fraction of source rows the result was computed from (1 = full scan). + // Returned counts are pre-scaled by 1/sampling. + sampling: yupNumber().min(0).max(1).optional().default(1), + routes: yupArray(yupObject({ + path: yupString().defined(), + clicks: yupNumber().integer().defined(), + users: yupNumber().integer().defined(), + replays: yupNumber().integer().defined(), + }).defined()).optional().default([]), + users: yupArray(yupObject({ + id: yupString().defined(), + display_name: yupString().nullable().defined(), + primary_email: yupString().nullable().defined(), + profile_image_url: yupString().nullable().defined(), + clicks: yupNumber().integer().defined(), + replays: yupNumber().integer().defined(), + last_event_at_millis: yupNumber().defined(), + }).defined()).optional().default([]), + replays: yupArray(yupObject({ + id: yupString().defined(), + user_id: yupString().nullable().defined(), + route_path: yupString().nullable().defined(), + viewport_width: yupNumber().integer().nullable().defined(), + viewport_height: yupNumber().integer().nullable().defined(), + clicks: yupNumber().integer().defined(), + last_event_at_millis: yupNumber().defined(), + }).defined()).optional().default([]), + selectors: yupArray(yupObject({ + selector: yupString().defined(), + clicks: yupNumber().integer().defined(), + }).defined()).optional().default([]), + // PostHog-style aggregated element identities. Resilient to DOM drift + // because the chain encodes ancestor tags/classes/attrs/text rather than + // a positional CSS selector. + elements: yupArray(yupObject({ + elements_chain: yupString().defined(), + elements_text: yupString().defined(), + tag_name: yupString().defined(), + href: yupString().nullable().defined(), + clicks: yupNumber().integer().defined(), + }).defined()).optional().default([]), +}).defined(); + // Recent "currently live" users keyed by ISO country code. Populated by // joining a bounded ClickHouse selection from the live `$token-refresh` window // with the corresponding Prisma profile rows, so the overview globe can render @@ -243,3 +304,8 @@ export type MetricsRecentUser = yup.InferType; export type MetricsResponse = yup.InferType; export type MetricsUserCounts = yup.InferType; export type UserActivityResponse = yup.InferType; +export type AnalyticsHeatmapKind = yup.InferType; +export type AnalyticsHeatmapDevice = yup.InferType; +export type AnalyticsHeatmapCell = yup.InferType; +export type AnalyticsHeatmapResponse = yup.InferType; +export type AnalyticsHeatmapTokenResponse = yup.InferType; diff --git a/packages/template/src/dev-tool/dev-tool-core.ts b/packages/template/src/dev-tool/dev-tool-core.ts index ba135e8c8e..655c5af572 100644 --- a/packages/template/src/dev-tool/dev-tool-core.ts +++ b/packages/template/src/dev-tool/dev-tool-core.ts @@ -3,6 +3,7 @@ import type { RequestLogEntry } from "@stackframe/stack-shared/dist/interface/client-interface"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; +import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import type { StackClientApp } from "../lib/stack-app"; import { envVars } from "../lib/env"; import { getBaseUrl } from "../lib/stack-app/apps/implementations/common"; @@ -17,7 +18,7 @@ import { clampTriggerPosition, getSnappedTriggerPlacement, resolveTriggerPositio // Types // --------------------------------------------------------------------------- -type TabId = 'overview' | 'customize' | 'ai' | 'dashboard' | 'console' | 'support'; +type TabId = 'overview' | 'heatmaps' | 'customize' | 'ai' | 'dashboard' | 'console' | 'support'; type TabResult = { element: HTMLElement, cleanup?: () => void }; @@ -61,6 +62,7 @@ const DOCS_URL = 'https://docs.hexclave.com'; const TABS: { id: TabId; label: string; icon: string }[] = [ { id: 'overview', label: 'Overview', icon: '' }, + { id: 'heatmaps', label: 'Heatmaps', icon: '' }, { id: 'customize', label: 'Customize', icon: '' }, { id: 'ai', label: 'AI', icon: '' }, { id: 'console', label: 'Console', icon: '' }, @@ -663,8 +665,13 @@ function createIframeTab(src: string, title: string, loadingMsg = 'Loading\u2026 // Overview tab // --------------------------------------------------------------------------- +function hasPersistentTokenStoreForDevTool(app: StackClientApp): boolean { + return app[stackAppInternalsSymbol].getConstructorOptions().tokenStore !== null; +} + function createOverviewTab(app: StackClientApp): TabResult { const container = h('div', { className: 'sdt-ov' }); + const hasPersistentTokenStore = hasPersistentTokenStoreForDevTool(app); // ── Identity card ────────────────────────────────────────────────────────── const heroCard = h('div', { className: 'sdt-ov-card sdt-ov-card-hero' }); @@ -718,6 +725,12 @@ function createOverviewTab(app: StackClientApp): TabResult { function rebuildActions() { actions.innerHTML = ''; + if (!hasPersistentTokenStore) { + userName.textContent = 'Current user unavailable'; + userEmail.textContent = 'This app was initialized without a token store'; + actions.appendChild(h('button', { className: 'sdt-ov-btn sdt-ov-btn-wide', disabled: 'true' }, 'Session actions unavailable')); + return; + } if (currentUser) { const signOutBtn = h('button', { className: 'sdt-ov-btn sdt-ov-btn-danger' }, 'Sign Out'); signOutBtn.disabled = loading; @@ -892,10 +905,13 @@ function createOverviewTab(app: StackClientApp): TabResult { function buildChecklist() { checksCard.innerHTML = ''; + const currentUserCheck = hasPersistentTokenStore + ? { ok: !!currentUser, label: 'Sign in a test user', hint: 'Use \u201cQuick Sign In\u201d above \u2192' } + : { ok: true, label: 'Current-user tools unavailable', hint: null }; const checks = [ { ok: !!projectId && projectId !== 'default', label: 'Project configured', hint: null }, { ok: hasActiveAuthMethod === true, label: 'Auth method active', hint: hasActiveAuthMethod === null ? 'Still checking project config' : null }, - { ok: !!currentUser, label: 'Sign in a test user', hint: 'Use \u201cQuick Sign In\u201d above \u2192' }, + currentUserCheck, ]; const passCount = checks.filter((c) => c.ok).length; const allGood = passCount === checks.length; @@ -937,6 +953,17 @@ function createOverviewTab(app: StackClientApp): TabResult { } async function refreshUser() { + if (!hasPersistentTokenStore) { + avatar.className = 'sdt-ov-avatar'; + avatar.textContent = '?'; + userName.textContent = 'Current user unavailable'; + userEmail.textContent = 'This app was initialized without a token store'; + authIndicator.style.display = 'none'; + currentUser = null; + rebuildActions(); + buildChecklist(); + return; + } try { currentUser = await app.getUser(); @@ -1754,6 +1781,1358 @@ function createAITab(app: StackClientApp): HTMLElement { return container; } +// --------------------------------------------------------------------------- +// Heatmaps tab +// --------------------------------------------------------------------------- + +type DevToolClickGroup = { + selector: string; + label: string; + count: number; + element: Element | null; + rect: DOMRect | null; +}; + +type HeatmapGroupOverlayElement = { + marker: HTMLElement; + outline: HTMLElement; +}; + +const HEATMAP_TOOL_ROOT_ID = '__hexclave-dev-tool-root'; +const HEATMAP_TOKEN_STORAGE_KEY = 'hexclave-heatmap-overlay-token'; +const HEATMAP_ORIGIN_STORAGE_KEY = 'hexclave-heatmap-overlay-origin'; +const HEATMAP_PROJECT_STORAGE_KEY = 'hexclave-heatmap-overlay-project-id'; +const HEATMAP_FILTERS_STORAGE_KEY = 'hexclave-heatmap-overlay-filters'; + +type HeatmapRangeKey = '24h' | '7d' | '30d'; +type HeatmapDeviceKey = 'all' | 'mobile' | 'tablet' | 'laptop' | 'desktop' | 'widescreen' | 'tv'; + +type HeatmapFilters = { + range: HeatmapRangeKey, + device: HeatmapDeviceKey, + urlPattern: string, + elementSearch: string, +}; + +const HEATMAP_DEFAULT_FILTERS: HeatmapFilters = { + range: '7d', + device: 'all', + urlPattern: '', + elementSearch: '', +}; + +const HEATMAP_RANGE_MS: Record = { + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, +}; + +function isHeatmapRangeKey(value: unknown): value is HeatmapRangeKey { + return value === '24h' || value === '7d' || value === '30d'; +} +function isHeatmapDeviceKey(value: unknown): value is HeatmapDeviceKey { + return value === 'all' || value === 'mobile' || value === 'tablet' || value === 'laptop' || value === 'desktop' || value === 'widescreen' || value === 'tv'; +} +const HEATMAP_BLOCKED_POINTER_EVENTS = ['pointerdown', 'mousedown', 'mouseup', 'click', 'dblclick', 'auxclick', 'contextmenu'] as const; +const HEATMAP_BLOCKED_KEY_EVENTS = ['keydown', 'keypress', 'keyup', 'beforeinput', 'input'] as const; +const HEATMAP_DOM_INDEX_DEBOUNCE_MS = 250; + +type DevToolServerHeatmapSelector = { + selector: string; + clicks: number; +}; + +type DevToolServerHeatmapElement = { + elementsChain: string; + elementsText: string; + tagName: string; + href: string | null; + clicks: number; +}; + +type DevToolServerHeatmap = { + path: string; + // True aggregate click total returned for the active filter (summed across + // every matching route), independent of how many elements can be drawn on the + // current page's DOM. The overlay can only render elements that exist on the + // page you're viewing, but this count reflects the full pattern. + totalClicks: number; + selectors: DevToolServerHeatmapSelector[]; + elements: DevToolServerHeatmapElement[]; +}; + +type ElementsChainSegment = { + tag: string; + classes: string[]; + attrs: Record; + text: string | null; + nthChild: number | null; + nthOfType: number | null; + href: string | null; +}; + +function parseElementsChain(chain: string): ElementsChainSegment[] { + // Split top-level by ';' respecting quoted strings. + const segments: string[] = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < chain.length; i++) { + const ch = chain[i]; + if (ch === '\\' && i + 1 < chain.length) { + current += ch + chain[i + 1]; + i += 1; + continue; + } + if (ch === '"') { + inQuotes = !inQuotes; + current += ch; + continue; + } + if (ch === ';' && !inQuotes) { + segments.push(current); + current = ''; + continue; + } + current += ch; + } + if (current.length > 0) { + segments.push(current); + } + return segments.map(parseElementsChainSegment).filter((segment): segment is ElementsChainSegment => segment != null); +} + +function parseElementsChainSegment(segment: string): ElementsChainSegment | null { + const trimmed = segment.trim(); + if (trimmed === '') return null; + + // Find first ':' at top level — separates tag/classes prefix from attribute pairs. + let prefixEnd = trimmed.length; + let inQuotes = false; + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i]; + if (ch === '\\' && i + 1 < trimmed.length) { + i += 1; + continue; + } + if (ch === '"') { + inQuotes = !inQuotes; + continue; + } + if (ch === ':' && !inQuotes) { + prefixEnd = i; + break; + } + } + + const prefix = trimmed.slice(0, prefixEnd); + const rest = trimmed.slice(prefixEnd); + const prefixParts = prefix.split('.'); + const tag = prefixParts[0].trim().toLowerCase(); + if (tag === '') return null; + const classes = prefixParts.slice(1).map((c) => c.trim()).filter((c) => c !== ''); + + const attrs: Record = {}; + let nthChild: number | null = null; + let nthOfType: number | null = null; + let text: string | null = null; + let href: string | null = null; + + // Parse :key="value" pairs from rest. + let i = 0; + while (i < rest.length) { + if (rest[i] !== ':') { + i += 1; + continue; + } + i += 1; // skip ':' + // read key up to '=' + let keyEnd = i; + while (keyEnd < rest.length && rest[keyEnd] !== '=' && rest[keyEnd] !== ':') keyEnd += 1; + const key = rest.slice(i, keyEnd).trim(); + if (keyEnd >= rest.length || rest[keyEnd] !== '=') { + i = keyEnd; + continue; + } + let valStart = keyEnd + 1; + if (rest[valStart] !== '"') { + // unquoted — read until next ':' at top level + let end = valStart; + while (end < rest.length && rest[end] !== ':') end += 1; + const value = rest.slice(valStart, end); + const result = applyElementsChainAttr(key, value); + if (result.nthChild != null) nthChild = result.nthChild; + if (result.nthOfType != null) nthOfType = result.nthOfType; + if (result.text != null) text = result.text; + if (result.href != null) href = result.href; + if (result.attrKey != null) attrs[result.attrKey] = result.attrValue ?? ''; + i = end; + continue; + } + // quoted value — find unescaped closing quote + valStart += 1; + let end = valStart; + let value = ''; + while (end < rest.length) { + const ch = rest[end]; + if (ch === '\\' && end + 1 < rest.length) { + const next = rest[end + 1]; + if (next === '"' || next === '\\') { + value += next; + end += 2; + continue; + } + value += ch; + end += 1; + continue; + } + if (ch === '"') break; + value += ch; + end += 1; + } + const result = applyElementsChainAttr(key, value); + if (result.nthChild != null) nthChild = result.nthChild; + if (result.nthOfType != null) nthOfType = result.nthOfType; + if (result.text != null) text = result.text; + if (result.href != null) href = result.href; + if (result.attrKey != null) attrs[result.attrKey] = result.attrValue ?? ''; + i = end + 1; // skip closing quote + } + + return { tag, classes, attrs, text, nthChild, nthOfType, href }; +} + +type ElementsChainAttrResult = { + nthChild?: number, + nthOfType?: number, + text?: string, + href?: string, + attrKey?: string, + attrValue?: string, +}; + +function applyElementsChainAttr(key: string, value: string): ElementsChainAttrResult { + if (key === 'nth-child') { + const n = Number.parseInt(value, 10); + return Number.isFinite(n) ? { nthChild: n } : {}; + } + if (key === 'nth-of-type') { + const n = Number.parseInt(value, 10); + return Number.isFinite(n) ? { nthOfType: n } : {}; + } + if (key === 'text') { + return { text: value }; + } + if (key === 'href') { + return { href: value, attrKey: key, attrValue: value }; + } + if (key.startsWith('attr__')) { + return { attrKey: key.slice('attr__'.length), attrValue: value }; + } + return { attrKey: key, attrValue: value }; +} + +function cssEscapeAttrValue(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function readChainAttr(segment: ElementsChainSegment, attr: string): string { + if (!Object.prototype.hasOwnProperty.call(segment.attrs, attr)) return ''; + const value = segment.attrs[attr]; + return typeof value === 'string' ? value : ''; +} + +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, (ch) => `\\${ch}`); +} + +function formatHeatmapCount(value: number): string { + if (value >= 1000) return `${Math.round(value / 100) / 10}k`; + return String(value); +} + +function getHeatmapHue(count: number, maxCount: number): number { + if (maxCount <= 1) return 185; + const intensity = Math.min(1, count / maxCount); + return 185 - Math.round(intensity * 155); +} + +// Use event.composedPath() rather than target.closest(): the path is captured +// at dispatch time and stays valid even if the original target has since been +// detached from the DOM (e.g. an icon's / that was replaced by an +// innerHTML reset between mousedown and click). With .closest(), detached +// targets have no ancestors so the check spuriously returns false, the page- +// interaction blocker eats the click, and the icon button looks dead. +function isInsideDevToolEvent(event: Event): boolean { + const path = typeof event.composedPath === 'function' ? event.composedPath() : []; + for (const node of path) { + if (node instanceof Element && node.id === HEATMAP_TOOL_ROOT_ID) { + return true; + } + } + // Fallback for environments without composedPath: best-effort ancestor walk. + const target = event.target; + if (target instanceof Element) { + return target.closest(`#${HEATMAP_TOOL_ROOT_ID}`) != null; + } + return false; +} + +function isHeatmapNavigationModifierEvent(event: Event): event is MouseEvent { + return event instanceof MouseEvent && (event.metaKey || event.ctrlKey); +} + +function getHeatmapNavigationHref(target: EventTarget | null): string | null { + if (!(target instanceof Element)) { + return null; + } + const link = target.closest('a[href]'); + if (!(link instanceof HTMLAnchorElement)) { + return null; + } + if (link.hasAttribute('download')) { + return null; + } + const href = link.href; + if (href === '' || href.startsWith('javascript:')) { + return null; + } + return href; +} + +function maybeNavigateFromHeatmapModifierClick(event: Event): boolean { + if (!isHeatmapNavigationModifierEvent(event)) { + return false; + } + if (event.type !== 'click') { + return true; + } + + const href = getHeatmapNavigationHref(event.target); + if (href == null) { + return true; + } + + // Drop a sentinel into sessionStorage so the dev tool on the next page can + // auto-reopen straight back into the heatmap tab. We can't soft-navigate + // from outside the host framework reliably (Next.js App Router gates on a + // private history-state marker, so a generic pushState+popstate doesn't + // re-render the tree), so we hard-nav and rehydrate the panel on load. + try { + sessionStorage.setItem('hexclave-heatmap-overlay-resume', '1'); + } catch { + // ignore (private mode, etc.) + } + window.location.assign(href); + return true; +} + +function getReadableElementLabel(element: Element): string { + const ariaLabel = element.getAttribute('aria-label'); + if (ariaLabel != null && ariaLabel.trim() !== '') { + return ariaLabel.trim().slice(0, 80); + } + const title = element.getAttribute('title'); + if (title != null && title.trim() !== '') { + return title.trim().slice(0, 80); + } + const text = element.textContent.trim().replace(/\s+/g, ' '); + if (text !== '') { + return text.slice(0, 80); + } + return element.tagName.toLowerCase(); +} + +function isElementVisibleForHeatmap(element: Element): boolean { + if (element.closest(`#${HEATMAP_TOOL_ROOT_ID}`) != null) { + return false; + } + if (element.closest('[hidden], [aria-hidden="true"], [inert]') != null) { + return false; + } + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return false; + } + const style = window.getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { + return false; + } + return true; +} + +function getElementFromSelector(selector: string): Element | null { + try { + const elements = Array.from(document.querySelectorAll(selector)); + return elements.find(isElementVisibleForHeatmap) ?? null; + } catch { + return null; + } +} + +function getProjectHeatmapTokenStorageKey(projectId: string): string { + return `${HEATMAP_TOKEN_STORAGE_KEY}:${projectId}`; +} + +function getProjectHeatmapOriginStorageKey(projectId: string): string { + return `${HEATMAP_ORIGIN_STORAGE_KEY}:${projectId}`; +} + +function getSessionStorageString(key: string): string | null { + try { + const value = sessionStorage.getItem(key); + return value == null || value.trim() === '' ? null : value; + } catch { + return null; + } +} + +function getActiveHeatmapProjectId(fallbackProjectId: string): string { + return getSessionStorageString(HEATMAP_PROJECT_STORAGE_KEY) ?? fallbackProjectId; +} + +function removeSessionStorageItem(key: string): void { + try { + sessionStorage.removeItem(key); + } catch { + // Storage can be blocked in private or embedded contexts; the toolbar keeps + // rendering the actionable error state in that case. + } +} + +function getJwtPayloadProjectId(token: string): string | null { + const tokenParts = token.split('.'); + if (tokenParts.length < 2 || tokenParts[1] === '') { + return null; + } + try { + const payloadPart = tokenParts[1]; + const normalized = payloadPart.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); + const payload: unknown = JSON.parse(atob(padded)); + if (typeof payload !== 'object' || payload === null) { + return null; + } + const projectId = Reflect.get(payload, 'project_id'); + return typeof projectId === 'string' ? projectId : null; + } catch { + return null; + } +} + +function getHeatmapTokenFromStorage(projectId: string): string | null { + const activeProjectId = getActiveHeatmapProjectId(projectId); + const projectToken = getSessionStorageString(getProjectHeatmapTokenStorageKey(activeProjectId)); + if (projectToken != null) { + return projectToken; + } + const legacyToken = getSessionStorageString(HEATMAP_TOKEN_STORAGE_KEY); + if (legacyToken == null) { + return null; + } + const legacyProjectId = getJwtPayloadProjectId(legacyToken); + return legacyProjectId == null || legacyProjectId === activeProjectId ? legacyToken : null; +} + +function getHeatmapOriginFromStorage(projectId: string): string | null { + const activeProjectId = getActiveHeatmapProjectId(projectId); + return getSessionStorageString(getProjectHeatmapOriginStorageKey(activeProjectId)) ?? getSessionStorageString(HEATMAP_ORIGIN_STORAGE_KEY); +} + +function clearHeatmapTokenStorage(projectId: string): void { + const activeProjectId = getActiveHeatmapProjectId(projectId); + removeSessionStorageItem(getProjectHeatmapTokenStorageKey(activeProjectId)); + removeSessionStorageItem(getProjectHeatmapOriginStorageKey(activeProjectId)); + removeSessionStorageItem(HEATMAP_PROJECT_STORAGE_KEY); + const legacyToken = getSessionStorageString(HEATMAP_TOKEN_STORAGE_KEY); + const legacyProjectId = legacyToken == null ? null : getJwtPayloadProjectId(legacyToken); + if (legacyProjectId == null || legacyProjectId === activeProjectId) { + removeSessionStorageItem(HEATMAP_TOKEN_STORAGE_KEY); + removeSessionStorageItem(HEATMAP_ORIGIN_STORAGE_KEY); + } +} + +function readNumberProperty(value: unknown, key: string): number | null { + if (typeof value !== 'object' || value === null) { + return null; + } + const property = Reflect.get(value, key); + return typeof property === 'number' && Number.isFinite(property) ? property : null; +} + +function readStringProperty(value: unknown, key: string): string | null { + if (typeof value !== 'object' || value === null) { + return null; + } + const property = Reflect.get(value, key); + return typeof property === 'string' && property !== '' ? property : null; +} + +function parseServerHeatmapResponse(value: unknown, path: string): DevToolServerHeatmap { + const selectorsValue = typeof value === 'object' && value !== null ? Reflect.get(value, 'selectors') : undefined; + const selectors: DevToolServerHeatmapSelector[] = []; + if (Array.isArray(selectorsValue)) { + for (const selectorValue of selectorsValue) { + const selector = readStringProperty(selectorValue, 'selector'); + const clicks = readNumberProperty(selectorValue, 'clicks'); + if (selector != null && clicks != null) { + selectors.push({ selector, clicks }); + } + } + } + + const elementsValue = typeof value === 'object' && value !== null ? Reflect.get(value, 'elements') : undefined; + const elements: DevToolServerHeatmapElement[] = []; + if (Array.isArray(elementsValue)) { + for (const elementValue of elementsValue) { + const elementsChain = readStringProperty(elementValue, 'elements_chain'); + const clicks = readNumberProperty(elementValue, 'clicks'); + if (elementsChain == null || clicks == null) continue; + elements.push({ + elementsChain, + elementsText: readStringProperty(elementValue, 'elements_text') ?? '', + tagName: readStringProperty(elementValue, 'tag_name') ?? '', + href: readStringProperty(elementValue, 'href'), + clicks, + }); + } + } + + const routesValue = typeof value === 'object' && value !== null ? Reflect.get(value, 'routes') : undefined; + let totalClicks = 0; + if (Array.isArray(routesValue)) { + for (const routeValue of routesValue) { + const clicks = readNumberProperty(routeValue, 'clicks'); + if (clicks != null) totalClicks += clicks; + } + } + + return { path, totalClicks, selectors, elements }; +} + +// Heuristic: does this path segment look like an opaque per-entity id (a UUID, +// numeric id, Mongo ObjectId, ULID, etc.) rather than a human-readable slug? +// Used to auto-wildcard slug routes so a single heatmap pattern aggregates +// across every user/team instead of just the one currently in the URL. +function isDynamicPathSegment(segment: string): boolean { + if (segment === '') return false; + let decoded = segment; + try { + decoded = decodeURIComponent(segment); + } catch { + // keep the raw segment if it isn't valid percent-encoding + } + if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(decoded)) return true; // UUID + if (/^[0-9a-f]{32}$/i.test(decoded)) return true; // UUID without dashes / md5 + if (/^[0-9a-f]{24}$/i.test(decoded)) return true; // Mongo ObjectId + if (/^[0-9A-HJKMNP-TV-Z]{26}$/i.test(decoded)) return true; // ULID + if (/^\d+$/.test(decoded)) return true; // numeric id + return false; +} + +// Turn the current pathname into a heatmap URL pattern by replacing id-like +// segments with `*` (PostHog-style wildcards). Stable slugs are preserved so +// e.g. `/teams//settings` becomes `/teams/*/settings`. +function wildcardizePathname(pathname: string): string { + const trailingSlash = pathname.length > 1 && pathname.endsWith('/'); + const segments = pathname.split('/').map((segment) => (isDynamicPathSegment(segment) ? '*' : segment)); + const joined = segments.join('/'); + return trailingSlash ? `${joined}/` : joined; +} + +// Does `path` match a PostHog-style URL pattern (where `*` is a wildcard)? +// Used to tell the user when the page they're on isn't covered by the pattern, +// so the overlay can't be drawn here even though aggregate data exists. +function patternMatchesPath(pattern: string, path: string): boolean { + if (pattern === '') return true; + const regexSource = pattern + .split('*') + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('.*'); + try { + return new RegExp(`^${regexSource}$`).test(path); + } catch { + return false; + } +} + +function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabResult { + const container = h('div', { className: 'sdt-hm' }); + const overlayRoot = h('div', { className: 'sdt-hm-overlay-root', 'aria-hidden': 'true' }); + const statsCount = h('div', { className: 'sdt-hm-stat-value' }, '0'); + const selectorCount = h('div', { className: 'sdt-hm-stat-value' }, '0'); + const viewportValue = h('div', { className: 'sdt-hm-stat-value' }, `${window.innerWidth}x${window.innerHeight}`); + const list = h('div', { className: 'sdt-hm-list' }); + const empty = h('div', { className: 'sdt-hm-empty' }, 'Paste a heatmap token from the dashboard to load aggregated element clicks for this page.'); + const status = h('div', { className: 'sdt-hm-token-status' }); + const overlayToggle = h('button', { className: 'sdt-hm-btn sdt-hm-btn-primary' }, 'Hide'); + const expandButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Expand heatmap options', title: 'Expand heatmap options' }); + const backButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Back', title: 'Back' }); + const miniClicks = h('span', { className: 'sdt-hm-toolbar-metric' }, '0 clicks'); + + function readStoredFilters(): HeatmapFilters { + try { + const raw = sessionStorage.getItem(HEATMAP_FILTERS_STORAGE_KEY); + if (raw == null) return { ...HEATMAP_DEFAULT_FILTERS }; + const parsed: unknown = JSON.parse(raw); + if (parsed == null || typeof parsed !== 'object') return { ...HEATMAP_DEFAULT_FILTERS }; + const obj = parsed as Record; + return { + range: isHeatmapRangeKey(obj.range) ? obj.range : HEATMAP_DEFAULT_FILTERS.range, + device: isHeatmapDeviceKey(obj.device) ? obj.device : HEATMAP_DEFAULT_FILTERS.device, + urlPattern: typeof obj.urlPattern === 'string' ? obj.urlPattern : HEATMAP_DEFAULT_FILTERS.urlPattern, + elementSearch: typeof obj.elementSearch === 'string' ? obj.elementSearch : HEATMAP_DEFAULT_FILTERS.elementSearch, + }; + } catch { + return { ...HEATMAP_DEFAULT_FILTERS }; + } + } + function persistFilters(next: HeatmapFilters) { + try { + sessionStorage.setItem(HEATMAP_FILTERS_STORAGE_KEY, JSON.stringify(next)); + } catch { + // ignore storage errors + } + } + + let currentPath = window.location.pathname; + let serverHeatmap: DevToolServerHeatmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; + let loadingServerHeatmap = false; + let serverHeatmapError: string | null = null; + let serverHeatmapRequestId = 0; + let overlayVisible = true; + let expanded = false; + let renderFrame = 0; + let overlayMode: 'hidden' | 'elements' = 'hidden'; + const groupOverlayElements = new Map(); + + // DOM-index cache for fast element-chain inference. + const domIndex = new Map(); + let domIndexDirty = true; + let domIndexDebounce = 0; + function rebuildDomIndex() { + domIndex.clear(); + const all = document.querySelectorAll('*'); + for (const el of all) { + if (!isElementVisibleForHeatmap(el)) continue; + const tag = el.tagName.toLowerCase(); + const bucket = domIndex.get(tag) ?? []; + bucket.push(el); + domIndex.set(tag, bucket); + } + domIndexDirty = false; + } + function ensureDomIndex() { + if (domIndexDirty) rebuildDomIndex(); + } + function invalidateDomIndex() { + domIndexDirty = true; + } + function scheduleDomIndexInvalidation() { + if (domIndexDebounce !== 0) { + window.clearTimeout(domIndexDebounce); + } + domIndexDebounce = window.setTimeout(() => { + domIndexDebounce = 0; + invalidateDomIndex(); + scheduleRender(); + }, HEATMAP_DOM_INDEX_DEBOUNCE_MS); + } + + function isElementChainCandidateUnique(matches: Element[]): Element | null { + const visible = matches.filter(isElementVisibleForHeatmap); + return visible.length === 1 ? visible[0] : null; + } + + function queryUniqueBySelector(selector: string): Element | null { + try { + const all = Array.from(document.querySelectorAll(selector)); + return isElementChainCandidateUnique(all); + } catch { + return null; + } + } + + function elementMatchesSegment(element: Element, segment: ElementsChainSegment, useClasses: boolean): boolean { + if (element.tagName.toLowerCase() !== segment.tag) return false; + if (useClasses) { + for (const cls of segment.classes) { + if (!element.classList.contains(cls)) return false; + } + } + return true; + } + + function ancestorMatchesChain(leaf: Element, chain: ElementsChainSegment[], useClasses: boolean, useNthOfType: boolean, useNthChild: boolean): boolean { + let cursor: Element | null = leaf; + for (let i = 0; i < chain.length; i++) { + if (cursor == null) return false; + const segment = chain[i]; + if (!elementMatchesSegment(cursor, segment, useClasses)) return false; + if (useNthOfType && segment.nthOfType != null) { + if (computeNthOfType(cursor) !== segment.nthOfType) return false; + } + if (useNthChild && segment.nthChild != null) { + if (computeNthChild(cursor) !== segment.nthChild) return false; + } + cursor = cursor.parentElement; + } + return true; + } + + function computeNthOfType(el: Element): number { + let n = 1; + let sib = el.previousElementSibling; + const tag = el.tagName; + while (sib != null) { + if (sib.tagName === tag) n += 1; + sib = sib.previousElementSibling; + } + return n; + } + + function computeNthChild(el: Element): number { + let n = 1; + let sib = el.previousElementSibling; + while (sib != null) { + n += 1; + sib = sib.previousElementSibling; + } + return n; + } + + function inferElementFromChain(chain: ElementsChainSegment[]): Element | null { + if (chain.length === 0) return null; + const leaf = chain[0]; + + // 1. Stable attribute selectors on leaf (no tag). + const stableAttrOrder: Array<{ attr: string, prefix?: string }> = [ + { attr: 'data-hexclave-id' }, + { attr: 'data-testid' }, + { attr: 'data-test-id' }, + { attr: 'name' }, + ]; + for (const { attr } of stableAttrOrder) { + const value = readChainAttr(leaf, attr); + if (value === '') continue; + const sel = `[${attr}="${cssEscapeAttrValue(value)}"]`; + const match: Element | null = queryUniqueBySelector(sel); + if (match) return match; + } + const id = readChainAttr(leaf, 'id'); + if (id !== '') { + const match: Element | null = queryUniqueBySelector(`#${cssEscapeIdent(id)}`); + if (match) return match; + } + if (leaf.href != null && leaf.href !== '' && leaf.tag === 'a') { + const match: Element | null = queryUniqueBySelector(`a[href="${cssEscapeAttrValue(leaf.href)}"]`); + if (match) return match; + } + + // 2. Tag + stable attribute on the leaf. + const otherStableAttrs = ['aria-label', 'role', 'placeholder', 'title', 'type']; + for (const attr of otherStableAttrs) { + const value = readChainAttr(leaf, attr); + if (value === '') continue; + const sel = `${leaf.tag}[${attr}="${cssEscapeAttrValue(value)}"]`; + const match: Element | null = queryUniqueBySelector(sel); + if (match) return match; + } + + // 3, 4, 5: walk the DOM index by leaf tag, score the chain. + ensureDomIndex(); + const candidates = domIndex.get(leaf.tag) ?? []; + if (candidates.length === 0) return null; + + // Variant 3: tag.classes across the chain, no nth. + const v3: Element[] = []; + for (const candidate of candidates) { + if (ancestorMatchesChain(candidate, chain, true, false, false)) v3.push(candidate); + } + const u3 = isElementChainCandidateUnique(v3); + if (u3 != null) return u3; + + // Variant 4: tag.classes + nth-of-type. + const v4: Element[] = []; + for (const candidate of candidates) { + if (ancestorMatchesChain(candidate, chain, true, true, false)) v4.push(candidate); + } + const u4 = isElementChainCandidateUnique(v4); + if (u4 != null) return u4; + + // Variant 5: tag.classes + nth-child. + const v5: Element[] = []; + for (const candidate of candidates) { + if (ancestorMatchesChain(candidate, chain, true, true, true)) v5.push(candidate); + } + const u5 = isElementChainCandidateUnique(v5); + if (u5 != null) return u5; + + return null; + } + + setHtml(backButton, ''); + const chevronUpSvg = ''; + const chevronDownSvg = ''; + setHtml(expandButton, chevronUpSvg); + + const toolbar = h('div', { className: 'sdt-hm-toolbar' }, + backButton, + h('div', { className: 'sdt-hm-toolbar-main' }, + h('div', { className: 'sdt-hm-toolbar-title' }, 'Heatmap'), + h('div', { className: 'sdt-hm-toolbar-subtitle' }, 'Page locked while inspecting'), + ), + miniClicks, + expandButton, + ); + const stats = h('div', { className: 'sdt-hm-stats' }, + h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Clicks'), statsCount), + h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Elements'), selectorCount), + h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Viewport'), viewportValue), + ); + + const note = h('div', { className: 'sdt-hm-note' }, + 'Heatmap mode blocks page clicks and keyboard input outside this toolbar while keeping every toolbar control interactive.' + ); + + let filters: HeatmapFilters = readStoredFilters(); + let filterReloadDebounce = 0; + // When the user hasn't typed a custom pattern, the URL pattern field mirrors + // the current route with id-like segments auto-wildcarded (`/teams/*/settings`) + // so the heatmap aggregates across all entities. A stored non-empty pattern + // means the user took manual control, so we leave it alone. + let urlPatternUserEdited = filters.urlPattern.trim() !== ''; + + function getEffectiveUrlPattern(): string { + if (urlPatternUserEdited) return filters.urlPattern.trim(); + return wildcardizePathname(window.location.pathname); + } + // Reflect the current route into the field while in auto mode. No-op once the + // user has typed their own pattern. + function syncAutoUrlPattern() { + if (urlPatternUserEdited) return; + const auto = wildcardizePathname(window.location.pathname); + if (urlPatternInput.value !== auto) { + urlPatternInput.value = auto; + } + } + + function makeFilterSelect(options: Array<[string, string]>, value: string): HTMLSelectElement { + const el = h('select', { className: 'sdt-hm-filter-input' }) as HTMLSelectElement; + for (const [optValue, label] of options) { + const opt = h('option', { value: optValue }, label) as HTMLOptionElement; + el.appendChild(opt); + } + el.value = value; + return el; + } + + const rangeSelect = makeFilterSelect([ + ['24h', 'Last 24h'], + ['7d', 'Last 7 days'], + ['30d', 'Last 30 days'], + ], filters.range); + const deviceSelect = makeFilterSelect([ + ['all', 'All viewports'], + ['mobile', 'Mobile'], + ['tablet', 'Tablet'], + ['laptop', 'Laptop'], + ['desktop', 'Desktop'], + ['widescreen', 'Widescreen'], + ['tv', 'TV'], + ], filters.device); + const urlPatternInput = h('input', { + className: 'sdt-hm-filter-input', + type: 'text', + placeholder: '/products/*', + spellcheck: 'false', + autocomplete: 'off', + autocapitalize: 'off', + }) as HTMLInputElement; + urlPatternInput.value = getEffectiveUrlPattern(); + // Shown only while the active pattern doesn't cover the current page (see + // render); resets the field back to the auto-wildcarded current route. + const urlPatternReset = h('button', { + className: 'sdt-hm-filter-reset', + type: 'button', + title: 'Reset the URL pattern to the current page', + }, 'Reset') as HTMLButtonElement; + const elementSearchInput = h('input', { + className: 'sdt-hm-filter-input', + type: 'text', + placeholder: 'Search element text or tag', + spellcheck: 'false', + autocomplete: 'off', + autocapitalize: 'off', + }) as HTMLInputElement; + elementSearchInput.value = filters.elementSearch; + + function wrapFilterField(label: string, input: HTMLElement, action?: HTMLElement): HTMLElement { + const labelRow = h('span', { className: 'sdt-hm-filter-label-row' }, + h('span', { className: 'sdt-hm-filter-label' }, label), + ); + if (action != null) { + labelRow.appendChild(action); + } + return h('label', { className: 'sdt-hm-filter-field' }, + labelRow, + input, + ); + } + + const filterRow = h('div', { className: 'sdt-hm-filters' }, + h('div', { className: 'sdt-hm-filter-row' }, + wrapFilterField('Range', rangeSelect), + wrapFilterField('Viewport', deviceSelect), + wrapFilterField('URL pattern', urlPatternInput, urlPatternReset), + wrapFilterField('Element search', elementSearchInput), + ), + ); + + function scheduleFilterReload() { + if (filterReloadDebounce !== 0) { + window.clearTimeout(filterReloadDebounce); + } + filterReloadDebounce = window.setTimeout(() => { + filterReloadDebounce = 0; + runAsynchronously(loadServerHeatmap()); + }, 250); + } + + function updateFilters(next: Partial) { + filters = { ...filters, ...next }; + persistFilters(filters); + scheduleFilterReload(); + } + + let elementSearchDebounce = 0; + function updateElementSearch(value: string) { + filters = { ...filters, elementSearch: value }; + persistFilters(filters); + if (elementSearchDebounce !== 0) { + window.clearTimeout(elementSearchDebounce); + } + elementSearchDebounce = window.setTimeout(() => { + elementSearchDebounce = 0; + scheduleRender(); + }, 120); + } + + rangeSelect.addEventListener('change', () => { + if (isHeatmapRangeKey(rangeSelect.value)) updateFilters({ range: rangeSelect.value }); + }); + deviceSelect.addEventListener('change', () => { + if (isHeatmapDeviceKey(deviceSelect.value)) updateFilters({ device: deviceSelect.value }); + }); + urlPatternInput.addEventListener('input', () => { + const value = urlPatternInput.value; + // Clearing the field hands control back to auto mode (reflect the route). + urlPatternUserEdited = value.trim() !== ''; + updateFilters({ urlPattern: value }); + }); + urlPatternReset.addEventListener('click', () => { + // Hand control back to auto mode and reflect the current route immediately, + // so the pattern covers the page the overlay is bound to. + urlPatternUserEdited = false; + urlPatternInput.value = wildcardizePathname(window.location.pathname); + updateFilters({ urlPattern: '' }); + }); + elementSearchInput.addEventListener('input', () => { + updateElementSearch(elementSearchInput.value); + }); + + // Two regions: a fixed head (filters + a single action row pairing the stat + // chips with the overlay toggle) and a scrolling body (status, note, list). + // Keeping borders to a single head/body divider avoids the dense stack of + // bordered bands that made the expanded panel feel congested. + const actions = h('div', { className: 'sdt-hm-actions' }, stats, overlayToggle); + const head = h('div', { className: 'sdt-hm-head' }, filterRow, actions); + const body = h('div', { className: 'sdt-hm-body' }, status, note, list); + const details = h('div', { className: 'sdt-hm-details' }, head, body); + + function getGroups(): DevToolClickGroup[] { + const byKey = new Map(); + if (serverHeatmap.path !== currentPath) { + return []; + } + + const searchQuery = filters.elementSearch.trim().toLowerCase(); + const matchesSearch = (entry: DevToolServerHeatmapElement): boolean => { + if (searchQuery === '') return true; + const haystacks = [entry.elementsText, entry.tagName, entry.href ?? '', entry.elementsChain]; + return haystacks.some((value) => value.toLowerCase().includes(searchQuery)); + }; + + // Prefer the elements-chain inference path (PostHog-style). + if (serverHeatmap.elements.length > 0) { + ensureDomIndex(); + for (const elementEntry of serverHeatmap.elements) { + if (!matchesSearch(elementEntry)) continue; + const chain = parseElementsChain(elementEntry.elementsChain); + let element = chain.length > 0 ? inferElementFromChain(chain) : null; + if (element == null && elementEntry.href != null && elementEntry.href !== '' && elementEntry.tagName.toLowerCase() === 'a') { + element = queryUniqueBySelector(`a[href="${cssEscapeAttrValue(elementEntry.href)}"]`); + } + if (element == null) continue; + const key = elementEntry.elementsChain; + const existing = byKey.get(key); + if (existing != null) { + existing.count += elementEntry.clicks; + continue; + } + byKey.set(key, { + selector: key, + label: getReadableElementLabel(element), + count: elementEntry.clicks, + element, + rect: element.getBoundingClientRect(), + }); + } + } + + // Legacy selectors fallback (older backends or unresolved chains). + if (byKey.size === 0) { + for (const selectorHeatmap of serverHeatmap.selectors) { + if (searchQuery !== '' && !selectorHeatmap.selector.toLowerCase().includes(searchQuery)) continue; + const element = getElementFromSelector(selectorHeatmap.selector); + if (element == null) continue; + byKey.set(selectorHeatmap.selector, { + selector: selectorHeatmap.selector, + label: getReadableElementLabel(element), + count: selectorHeatmap.clicks, + element, + rect: element.getBoundingClientRect(), + }); + } + } + + return Array.from(byKey.values()) + .sort((a, b) => b.count - a.count || stringCompare(a.selector, b.selector)); + } + + function scheduleRender() { + cancelAnimationFrame(renderFrame); + renderFrame = requestAnimationFrame(render); + } + + function clearHeatmapOverlayElements() { + groupOverlayElements.clear(); + overlayRoot.replaceChildren(); + } + + function getHeatmapViewportSize(): { width: number, height: number } { + const visualViewport = window.visualViewport; + if (visualViewport != null) { + return { width: visualViewport.width, height: visualViewport.height }; + } + return { width: window.innerWidth, height: window.innerHeight }; + } + + function shouldShowElements(): boolean { + return overlayVisible; + } + + function renderOverlay(groups: DevToolClickGroup[]) { + const nextMode = shouldShowElements() ? 'elements' : 'hidden'; + if (overlayMode !== nextMode) { + overlayMode = nextMode; + clearHeatmapOverlayElements(); + } + if (!shouldShowElements()) { + return; + } + + const visibleGroupKeys = new Set(); + const maxCount = Math.max(1, ...groups.map((group) => group.count)); + for (const group of groups) { + if (group.rect == null || group.rect.width <= 0 || group.rect.height <= 0) { + continue; + } + visibleGroupKeys.add(group.selector); + const hue = getHeatmapHue(group.count, maxCount); + let overlayElement = groupOverlayElements.get(group.selector); + if (overlayElement == null) { + overlayElement = { + marker: h('div', { className: 'sdt-hm-marker' }), + outline: h('div', { className: 'sdt-hm-outline' }), + }; + groupOverlayElements.set(group.selector, overlayElement); + overlayRoot.append(overlayElement.outline, overlayElement.marker); + } + const { marker, outline } = overlayElement; + marker.title = `${group.count} clicks on ${group.selector}`; + marker.style.left = `${Math.round(group.rect.left + group.rect.width / 2)}px`; + marker.style.top = `${Math.round(group.rect.top + group.rect.height / 2)}px`; + marker.style.background = `hsla(${hue}, 96%, 58%, 0.94)`; + marker.style.boxShadow = `0 0 0 1px hsla(${hue}, 96%, 22%, 0.35), 0 8px 24px hsla(${hue}, 96%, 45%, 0.32)`; + marker.textContent = formatHeatmapCount(group.count); + + outline.style.left = `${group.rect.left}px`; + outline.style.top = `${group.rect.top}px`; + outline.style.width = `${group.rect.width}px`; + outline.style.height = `${group.rect.height}px`; + outline.style.borderColor = `hsla(${hue}, 96%, 58%, 0.5)`; + } + for (const [key, overlayElement] of groupOverlayElements) { + if (!visibleGroupKeys.has(key)) { + overlayElement.marker.remove(); + overlayElement.outline.remove(); + groupOverlayElements.delete(key); + } + } + } + + function renderList(groups: DevToolClickGroup[]) { + list.replaceChildren(); + if (groups.length === 0) { + list.appendChild(empty); + return; + } + for (const group of groups.slice(0, 30)) { + const row = h('button', { className: 'sdt-hm-row' }); + row.appendChild(h('span', { className: 'sdt-hm-row-count' }, formatHeatmapCount(group.count))); + const meta = h('span', { className: 'sdt-hm-row-meta' }, + h('span', { className: 'sdt-hm-row-label' }, group.label), + h('span', { className: 'sdt-hm-row-selector' }, group.selector), + ); + row.appendChild(meta); + row.addEventListener('click', () => { + group.element?.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' }); + }); + list.appendChild(row); + } + } + + function render() { + if (currentPath !== window.location.pathname) { + currentPath = window.location.pathname; + serverHeatmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; + serverHeatmapError = null; + syncAutoUrlPattern(); + runAsynchronously(loadServerHeatmap()); + } + const groups = getGroups(); + // Clicks mapped to an element that actually exists in the current DOM (what + // the overlay can draw) vs. the true aggregate the filter matched server-side. + const mappedClicks = groups.reduce((sum, group) => sum + group.count, 0); + const aggregateClicks = serverHeatmap.path === currentPath ? serverHeatmap.totalClicks : 0; + const viewport = getHeatmapViewportSize(); + statsCount.textContent = formatHeatmapCount(aggregateClicks); + selectorCount.textContent = formatHeatmapCount(groups.length); + viewportValue.textContent = `${Math.round(viewport.width)}x${Math.round(viewport.height)}`; + overlayToggle.textContent = overlayVisible ? 'Hide overlay' : 'Show overlay'; + // A pattern that doesn't cover the current page means the overlay can't draw + // here, so offer a one-click reset back to the current route. + const effectiveUrlPattern = getEffectiveUrlPattern(); + const urlPatternMatchesPath = patternMatchesPath(effectiveUrlPattern, currentPath); + urlPatternReset.classList.toggle('sdt-hm-filter-reset-visible', !urlPatternMatchesPath); + const token = getHeatmapTokenFromStorage(app.projectId); + const tokenOrigin = getHeatmapOriginFromStorage(app.projectId); + if (token == null) { + status.textContent = serverHeatmapError ?? `No ${getProjectHeatmapTokenStorageKey(getActiveHeatmapProjectId(app.projectId))} token in sessionStorage.`; + } else if (tokenOrigin != null && tokenOrigin !== window.location.origin) { + status.textContent = `Token was minted for ${tokenOrigin}, but this page is ${window.location.origin}. Generate a token for this exact origin.`; + } else if (loadingServerHeatmap) { + status.textContent = 'Loading aggregate clickmap...'; + } else if (serverHeatmapError != null) { + status.textContent = serverHeatmapError; + } else { + const scope = effectiveUrlPattern !== '' && effectiveUrlPattern !== currentPath ? effectiveUrlPattern : currentPath; + let message = `Loaded ${formatHeatmapCount(aggregateClicks)} aggregate clicks for ${scope}.`; + if (aggregateClicks === 0) { + message = `No clicks recorded for ${scope} in this range.`; + } else if (!urlPatternMatchesPath) { + // The overlay is bound to the page you're viewing; off-pattern pages + // can't render it. This is the "* / shows 0 dots" case made explicit. + message += ' This page isn’t covered by the pattern — reset it or open a matching page to see the overlay.'; + } else if (groups.length === 0) { + message += ' No matching elements found on this page yet.'; + } else if (mappedClicks < aggregateClicks) { + message += ` ${formatHeatmapCount(mappedClicks)} mapped to elements on this page.`; + } + status.textContent = message; + } + status.classList.toggle('sdt-hm-token-status-error', serverHeatmapError != null || (token != null && tokenOrigin != null && tokenOrigin !== window.location.origin)); + miniClicks.textContent = `${formatHeatmapCount(aggregateClicks)} clicks`; + container.classList.toggle('sdt-hm-expanded', expanded); + expandButton.setAttribute('aria-expanded', String(expanded)); + expandButton.setAttribute('aria-label', expanded ? 'Collapse heatmap options' : 'Expand heatmap options'); + expandButton.title = expanded ? 'Collapse heatmap options' : 'Expand heatmap options'; + setHtml(expandButton, expanded ? chevronDownSvg : chevronUpSvg); + renderOverlay(groups); + renderList(groups); + } + + async function loadServerHeatmap() { + const requestId = serverHeatmapRequestId + 1; + serverHeatmapRequestId = requestId; + const isLatestRequest = () => requestId === serverHeatmapRequestId; + const token = getHeatmapTokenFromStorage(app.projectId); + if (token == null) { + serverHeatmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; + serverHeatmapError = null; + loadingServerHeatmap = false; + render(); + return; + } + const tokenOrigin = getHeatmapOriginFromStorage(app.projectId); + if (tokenOrigin != null && tokenOrigin !== window.location.origin) { + serverHeatmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; + serverHeatmapError = null; + loadingServerHeatmap = false; + render(); + return; + } + + loadingServerHeatmap = true; + serverHeatmapError = null; + render(); + try { + const until = new Date(); + const since = new Date(until.getTime() - HEATMAP_RANGE_MS[filters.range]); + const requestedPath = window.location.pathname; + const effectiveUrlPattern = getEffectiveUrlPattern(); + const body: Record = { + heatmap_token: token, + origin: window.location.origin, + since: since.toISOString(), + until: until.toISOString(), + }; + if (effectiveUrlPattern !== '') { + body.url_pattern = effectiveUrlPattern; + } else { + body.route_path = requestedPath; + } + if (filters.device !== 'all') { + body.device = filters.device; + } + const response = await app[stackAppInternalsSymbol].sendRequest("/analytics/heatmap", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, "client"); + if (!response.ok) { + throw new Error(`Heatmap request failed with HTTP ${response.status}`); + } + const responseBody: unknown = await response.json(); + if (!isLatestRequest()) { + return; + } + serverHeatmap = parseServerHeatmapResponse(responseBody, requestedPath); + } catch (error) { + if (!isLatestRequest()) { + return; + } + serverHeatmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; + if (error instanceof Error && error.message.includes('Heatmap token does not belong to this project')) { + clearHeatmapTokenStorage(app.projectId); + serverHeatmapError = 'The stored heatmap token belongs to another project. Generate a fresh token for this project.'; + } else { + serverHeatmapError = error instanceof Error ? error.message : 'Failed to load heatmap data'; + } + } finally { + if (!isLatestRequest()) { + return; + } + loadingServerHeatmap = false; + render(); + } + } + + function blockHeatmapPageInteraction(event: Event) { + if (isInsideDevToolEvent(event)) { + return; + } + const token = getHeatmapTokenFromStorage(app.projectId); + const tokenOrigin = getHeatmapOriginFromStorage(app.projectId); + if (token == null || (tokenOrigin != null && tokenOrigin !== window.location.origin) || serverHeatmapError != null) { + return; + } + + if (maybeNavigateFromHeatmapModifierClick(event)) { + if (event.type === 'click') { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + } + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + } + + overlayToggle.addEventListener('click', () => { + overlayVisible = !overlayVisible; + render(); + }); + backButton.addEventListener('click', onBack); + expandButton.addEventListener('click', () => { + expanded = !expanded; + render(); + }); + const onTokenUpdated = () => { + runAsynchronously(loadServerHeatmap()); + }; + const routePollInterval = window.setInterval(scheduleRender, 500); + const mutationObserver = new MutationObserver(() => { + scheduleDomIndexInvalidation(); + scheduleRender(); + }); + const visualViewport = window.visualViewport; + mutationObserver.observe(document.body, { attributes: true, childList: true, subtree: true }); + + document.body.appendChild(overlayRoot); + rebuildDomIndex(); + scheduleRender(); + for (const eventName of HEATMAP_BLOCKED_POINTER_EVENTS) { + window.addEventListener(eventName, blockHeatmapPageInteraction, true); + } + for (const eventName of HEATMAP_BLOCKED_KEY_EVENTS) { + window.addEventListener(eventName, blockHeatmapPageInteraction, true); + } + const onWindowResize = () => { + scheduleRender(); + }; + document.addEventListener('scroll', scheduleRender, true); + window.addEventListener('resize', onWindowResize); + visualViewport?.addEventListener('resize', scheduleRender); + visualViewport?.addEventListener('scroll', scheduleRender); + window.addEventListener('hexclave:heatmap-token-updated', onTokenUpdated); + render(); + runAsynchronously(loadServerHeatmap()); + + container.append(details, toolbar); + return { + element: container, + cleanup: () => { + cancelAnimationFrame(renderFrame); + if (domIndexDebounce !== 0) window.clearTimeout(domIndexDebounce); + if (filterReloadDebounce !== 0) window.clearTimeout(filterReloadDebounce); + if (elementSearchDebounce !== 0) window.clearTimeout(elementSearchDebounce); + window.clearInterval(routePollInterval); + mutationObserver.disconnect(); + clearHeatmapOverlayElements(); + domIndex.clear(); + for (const eventName of HEATMAP_BLOCKED_POINTER_EVENTS) { + window.removeEventListener(eventName, blockHeatmapPageInteraction, true); + } + for (const eventName of HEATMAP_BLOCKED_KEY_EVENTS) { + window.removeEventListener(eventName, blockHeatmapPageInteraction, true); + } + document.removeEventListener('scroll', scheduleRender, true); + window.removeEventListener('resize', onWindowResize); + visualViewport?.removeEventListener('resize', scheduleRender); + visualViewport?.removeEventListener('scroll', scheduleRender); + window.removeEventListener('hexclave:heatmap-token-updated', onTokenUpdated); + overlayRoot.remove(); + }, + }; +} + // --------------------------------------------------------------------------- // Dashboard tab // --------------------------------------------------------------------------- @@ -2173,6 +3552,7 @@ function createPanel( animateNextPanelGeometryChange(); } + panel.classList.toggle('sdt-panel-heatmap', tabId === 'heatmaps'); if (tabId === 'dashboard') { panel.classList.add('sdt-panel-fullscreen'); panel.style.width = ''; @@ -2181,6 +3561,11 @@ function createPanel( } panel.classList.remove('sdt-panel-fullscreen'); + if (tabId === 'heatmaps') { + panel.style.width = ''; + panel.style.height = ''; + return; + } panel.style.width = state.get().panelWidth + 'px'; panel.style.height = state.get().panelHeight + 'px'; } @@ -2188,6 +3573,7 @@ function createPanel( const tabs = getTabsForApp(app); const storedActiveTab = state.get().activeTab; const activeTab = tabs.some((tab) => tab.id === storedActiveTab) ? storedActiveTab : DEFAULT_STATE.activeTab; + let lastNonHeatmapTab: TabId = activeTab === 'heatmaps' ? 'overview' : activeTab; applyPanelMode(activeTab); @@ -2206,6 +3592,9 @@ function createPanel( const trailingControls = h('div', { className: 'sdt-tabbar-actions' }, docsLink, closeBtn); const tabBar = createTabBar(tabs, activeTab, (id) => { + if (id !== 'heatmaps') { + lastNonHeatmapTab = id as TabId; + } state.update({ activeTab: id as TabId }); applyPanelMode(id as TabId, { animate: true }); showTab(id as TabId); @@ -2244,6 +3633,14 @@ function createPanel( mountTab(pane, createOverviewTab(app)); break; } + case 'heatmaps': { + mountTab(pane, createHeatmapsTab(app, () => { + state.update({ activeTab: lastNonHeatmapTab }); + applyPanelMode(lastNonHeatmapTab, { animate: true }); + showTab(lastNonHeatmapTab); + })); + break; + } case 'customize': { mountTab(pane, createComponentsTab(app)); break; @@ -2384,6 +3781,17 @@ export function createDevTool(app: StackClientApp): () => void { wrapper.appendChild(panel.element); } + function openHeatmapPanel() { + state.update({ activeTab: 'heatmaps', isOpen: true }); + if (panel) { + const currentPanel = panel; + panel = null; + currentPanel.cleanup(); + currentPanel.element.remove(); + } + openPanel(); + } + function closePanel() { if (!panel) return; state.update({ isOpen: false }); @@ -2410,10 +3818,29 @@ export function createDevTool(app: StackClientApp): () => void { const trigger = createTrigger(togglePanel); wrapper.appendChild(trigger.element); + // Resume the heatmap panel after a Cmd+click hard navigation. The handler + // that performed the redirect drops a sentinel into sessionStorage; if it's + // present here, restore the heatmap tab as the active one and reopen the + // panel so the user picks up where they left off. + let shouldResumeHeatmap = false; + try { + if (sessionStorage.getItem('hexclave-heatmap-overlay-resume') === '1') { + shouldResumeHeatmap = true; + sessionStorage.removeItem('hexclave-heatmap-overlay-resume'); + } + } catch { + // ignore + } + if (shouldResumeHeatmap) { + state.update({ activeTab: 'heatmaps', isOpen: true }); + } + if (state.get().isOpen) { openPanel(); } + window.addEventListener('hexclave:heatmap-token-updated', openHeatmapPanel); + const removeRequestListener = app[stackAppInternalsSymbol].addRequestListener((entry: RequestLogEntry) => { const timestamp = Date.now(); logStore.addApiLog({ @@ -2441,6 +3868,7 @@ export function createDevTool(app: StackClientApp): () => void { setGlobalDevToolInstance(null); } trigger.cleanup(); + window.removeEventListener('hexclave:heatmap-token-updated', openHeatmapPanel); removeRequestListener(); panel?.cleanup(); if (root.parentNode) { diff --git a/packages/template/src/dev-tool/dev-tool-styles.ts b/packages/template/src/dev-tool/dev-tool-styles.ts index 7e0e3ef310..b5295b7cfd 100644 --- a/packages/template/src/dev-tool/dev-tool-styles.ts +++ b/packages/template/src/dev-tool/dev-tool-styles.ts @@ -99,7 +99,7 @@ export const devToolCSS = ` position: fixed; bottom: 60px; right: 16px; - z-index: 99998; + z-index: 2147483647; width: 800px; max-width: calc(100vw - 32px); height: 520px; @@ -133,6 +133,21 @@ export const devToolCSS = ` border-radius: 0; } + .stack-devtool .sdt-panel-heatmap { + left: 50%; + right: auto; + bottom: 18px; + width: min(680px, calc(100vw - 24px)); + max-width: calc(100vw - 24px); + height: auto; + max-height: calc(100vh - 36px); + transform: translateX(-50%); + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + } + .stack-devtool .sdt-panel-inner { display: flex; flex-direction: column; @@ -143,6 +158,12 @@ export const devToolCSS = ` animation: sdt-panel-enter 0.2s ease-out; } + .stack-devtool .sdt-panel-heatmap .sdt-panel-inner { + height: auto; + overflow: visible; + border-radius: 0; + } + .stack-devtool .sdt-panel-fullscreen .sdt-panel-inner { border-radius: 0; } @@ -151,6 +172,11 @@ export const devToolCSS = ` display: none; } + .stack-devtool .sdt-panel-heatmap .sdt-tabbar, + .stack-devtool .sdt-panel-heatmap .sdt-resize-handle { + display: none; + } + @keyframes sdt-panel-enter { from { opacity: 0; @@ -259,9 +285,15 @@ export const devToolCSS = ` } .stack-devtool .sdt-tabbar-actions { + position: sticky; + right: 0; + z-index: 2; display: flex; align-items: center; + align-self: stretch; gap: 4px; + padding-left: 6px; + background: inherit; flex-shrink: 0; } @@ -322,6 +354,11 @@ export const devToolCSS = ` min-height: 0; } + .stack-devtool .sdt-panel-heatmap .sdt-content { + flex: none; + overflow: visible; + } + .stack-devtool .sdt-panel-fullscreen .sdt-content { position: absolute; inset: 0; @@ -334,6 +371,10 @@ export const devToolCSS = ` inset: 0; } + .stack-devtool .sdt-panel-heatmap .sdt-tab-layers { + position: static; + } + .stack-devtool .sdt-tab-pane { position: absolute; inset: 0; @@ -344,6 +385,12 @@ export const devToolCSS = ` pointer-events: none; } + .stack-devtool .sdt-panel-heatmap .sdt-tab-pane { + position: static; + padding: 0; + overflow: visible; + } + .stack-devtool .sdt-tab-pane-iframe { padding: 0; overflow: hidden; @@ -2569,6 +2616,386 @@ export const devToolCSS = ` line-height: 1.4; } + /* --- Heatmaps --- */ + + .stack-devtool .sdt-hm { + height: auto; + display: flex; + flex-direction: column; + gap: 8px; + overflow: visible; + background: transparent; + } + + .stack-devtool .sdt-hm-toolbar { + display: flex; + align-items: center; + gap: 8px; + min-height: 44px; + padding: 6px 8px; + border: 1px solid var(--sdt-border); + border-radius: 999px; + background: var(--sdt-overlay-bg); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24); + backdrop-filter: blur(18px); + } + + .stack-devtool .sdt-hm-toolbar-main { + min-width: 0; + flex: 1; + padding-left: 2px; + } + + .stack-devtool .sdt-hm-toolbar-title { + font-size: 13px; + font-weight: 650; + color: var(--sdt-text); + line-height: 1.1; + } + + .stack-devtool .sdt-hm-toolbar-subtitle { + margin-top: 1px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10.5px; + color: var(--sdt-text-secondary); + } + + .stack-devtool .sdt-hm-toolbar-metric { + flex-shrink: 0; + border-radius: 999px; + background: var(--sdt-accent-muted); + color: var(--sdt-accent-hover); + padding: 5px 8px; + font-size: 11px; + font-weight: 700; + font-variant-numeric: tabular-nums; + } + + .stack-devtool .sdt-hm-icon-btn { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--sdt-text-secondary); + cursor: pointer; + } + + .stack-devtool .sdt-hm-icon-btn:hover { + background: var(--sdt-bg-hover); + color: var(--sdt-text); + } + + .stack-devtool .sdt-hm-details { + display: none; + max-height: min(460px, calc(100vh - 98px)); + overflow: hidden; + border: 1px solid var(--sdt-border); + border-radius: var(--sdt-radius-lg); + background: var(--sdt-bg); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.22); + } + + .stack-devtool .sdt-hm-expanded .sdt-hm-details { + display: flex; + flex-direction: column; + } + + .stack-devtool .sdt-hm-head { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 14px; + padding: 16px; + border-bottom: 1px solid var(--sdt-border-subtle); + } + + .stack-devtool .sdt-hm-filter-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + + .stack-devtool .sdt-hm-filter-field { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; + } + + .stack-devtool .sdt-hm-filter-label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + min-height: 13px; + } + + .stack-devtool .sdt-hm-filter-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--sdt-text-tertiary); + } + + .stack-devtool .sdt-hm-filter-reset { + display: none; + border: 0; + background: transparent; + padding: 0; + font: inherit; + font-family: var(--sdt-font); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--sdt-accent); + cursor: pointer; + } + + .stack-devtool .sdt-hm-filter-reset:hover { + color: var(--sdt-accent-hover); + } + + .stack-devtool .sdt-hm-filter-reset-visible { + display: inline-flex; + align-items: center; + } + + .stack-devtool .sdt-hm-filter-input { + height: 30px; + border-radius: var(--sdt-radius); + border: 1px solid var(--sdt-border-subtle); + background: var(--sdt-bg-elevated); + color: var(--sdt-text); + padding: 0 9px; + font: inherit; + font-size: 12px; + font-family: var(--sdt-font); + min-width: 0; + width: 100%; + box-sizing: border-box; + } + + .stack-devtool .sdt-hm-filter-input:focus { + outline: none; + border-color: var(--sdt-accent); + } + + .stack-devtool .sdt-hm-actions { + display: flex; + align-items: stretch; + gap: 10px; + } + + .stack-devtool .sdt-hm-actions .sdt-hm-btn { + height: auto; + flex-shrink: 0; + white-space: nowrap; + padding: 0 16px; + } + + .stack-devtool .sdt-hm-btn { + height: 30px; + border-radius: var(--sdt-radius); + border: 1px solid var(--sdt-border-subtle); + background: var(--sdt-bg-elevated); + color: var(--sdt-text-secondary); + padding: 0 10px; + font: inherit; + font-size: 12px; + cursor: pointer; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; + } + + .stack-devtool .sdt-hm-btn:hover { + background: var(--sdt-bg-hover); + color: var(--sdt-text); + transition: none; + } + + .stack-devtool .sdt-hm-btn-primary { + background: var(--sdt-accent); + border-color: var(--sdt-accent); + color: white; + } + + .stack-devtool .sdt-hm-stats { + flex: 1; + min-width: 0; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + } + + .stack-devtool .sdt-hm-stat { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + border-radius: var(--sdt-radius); + background: var(--sdt-bg-elevated); + padding: 7px 10px; + } + + .stack-devtool .sdt-hm-stat-label { + font-size: 9.5px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--sdt-text-tertiary); + } + + .stack-devtool .sdt-hm-stat-value { + font-size: 15px; + font-weight: 650; + color: var(--sdt-text); + font-variant-numeric: tabular-nums; + } + + .stack-devtool .sdt-hm-body { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 16px 14px; + } + + .stack-devtool .sdt-hm-note { + border-radius: var(--sdt-radius); + background: var(--sdt-info-muted); + color: var(--sdt-text-secondary); + padding: 9px 11px; + font-size: 11px; + line-height: 1.5; + } + + .stack-devtool .sdt-hm-token-status { + color: var(--sdt-text-secondary); + padding: 0 2px; + font-size: 11.5px; + line-height: 1.45; + } + + .stack-devtool .sdt-hm-token-status-error { + color: var(--sdt-error); + } + + .stack-devtool .sdt-hm-list { + display: flex; + flex-direction: column; + gap: 2px; + } + + .stack-devtool .sdt-hm-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 140px; + border-radius: var(--sdt-radius); + border: 1px dashed var(--sdt-border); + color: var(--sdt-text-tertiary); + font-size: 12px; + text-align: center; + padding: 0 16px; + } + + .stack-devtool .sdt-hm-row { + width: 100%; + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + align-items: center; + gap: 10px; + border: 0; + border-radius: var(--sdt-radius); + background: transparent; + color: var(--sdt-text); + padding: 8px; + text-align: left; + cursor: pointer; + font-family: var(--sdt-font); + } + + .stack-devtool .sdt-hm-row:hover { + background: var(--sdt-bg-hover); + } + + .stack-devtool .sdt-hm-row-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + height: 24px; + border-radius: 999px; + background: var(--sdt-accent-muted); + color: var(--sdt-accent-hover); + font-size: 12px; + font-weight: 700; + font-variant-numeric: tabular-nums; + } + + .stack-devtool .sdt-hm-row-meta { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; + } + + .stack-devtool .sdt-hm-row-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 600; + } + + .stack-devtool .sdt-hm-row-selector { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--sdt-font-mono); + font-size: 10.5px; + color: var(--sdt-text-tertiary); + } + + .sdt-hm-overlay-root { + position: fixed; + inset: 0; + z-index: 2147483646; + pointer-events: none; + } + + .sdt-hm-overlay-root .sdt-hm-marker { + position: fixed; + transform: translate(-50%, -50%); + min-width: 28px; + height: 24px; + border-radius: 999px; + padding: 0 8px; + display: flex; + align-items: center; + justify-content: center; + color: rgba(10, 10, 11, 0.92); + font: 700 12px/1 var(--sdt-font, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); + font-variant-numeric: tabular-nums; + } + + .sdt-hm-overlay-root .sdt-hm-outline { + position: fixed; + border: 1px solid; + border-radius: 4px; + background: rgba(99, 102, 241, 0.04); + } + /* --- Input area --- */ .stack-devtool .sdt-ai-input-area { diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 093d6cd6e4..8b3e97edb2 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -1,7 +1,7 @@ import { KnownErrors, HexclaveAdminInterface } from "@stackframe/stack-shared"; import { getProductionModeErrors } from "@stackframe/stack-shared/dist/helpers/production-mode"; import { InternalApiKeyCreateCrudResponse } from "@stackframe/stack-shared/dist/interface/admin-interface"; -import type { MetricsResponse, MetricsUserCounts, UserActivityResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@stackframe/stack-shared/dist/interface/crud/analytics"; import { EmailTemplateCrud } from "@stackframe/stack-shared/dist/interface/crud/email-templates"; import { InternalApiKeysCrud } from "@stackframe/stack-shared/dist/interface/crud/internal-api-keys"; @@ -1168,6 +1168,28 @@ export class _StackAdminAppImplIncomplete { + return await this._interface.getAnalyticsHeatmap(options); + } + + async createAnalyticsHeatmapToken(options: { origin: string }): Promise { + return await this._interface.createAnalyticsHeatmapToken(options); + } + async listSessionReplays(options?: ListSessionReplaysOptions): Promise { const response = await this._interface.listSessionReplays({ cursor: options?.cursor, diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts index e7cca3f0c6..b50296bd89 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.test.ts @@ -66,6 +66,138 @@ describe("EventTracker", () => { } }); + it("emits a PostHog-style elements_chain plus scaled pointer coords for $click", async () => { + vi.useFakeTimers(); + document.body.innerHTML = ` +
+
+ +
+
+ `; + + const sentBodies: string[] = []; + const tracker = new EventTracker({ + projectId: "internal", + sendBatch: async (body) => { + sentBodies.push(body); + return Result.ok(new Response()); + }, + }); + + try { + tracker.start(); + const button = document.querySelector("#save-btn"); + if (button == null) throw new Error("button missing"); + button.dispatchEvent(new MouseEvent("click", { + bubbles: true, + clientX: 100, + clientY: 200, + })); + + await advancePastFlush(); + + const payload = JSON.parse(sentBodies[0] ?? "{}") as { events: { event_type: string, data: Record }[] }; + const click = payload.events.find((event) => event.event_type === "$click"); + if (click == null) throw new Error("no $click event captured"); + + // elements_chain encodes the target leaf plus a few ancestors. Leaf is + // first; segments are `;`-delimited. Assert against substrings rather + // than the full string so jsdom layout quirks don't make this flaky. + const chain = click.data.elements_chain; + expect(typeof chain).toBe("string"); + expect(chain).toContain('button'); + expect(chain).toContain('attr__id="save-btn"'); + expect(chain).toContain('attr__data-testid="save"'); + expect(chain).toContain('attr__aria-label="Save project"'); + expect(chain).toContain('text="Save changes"'); + // Ancestor section is in the chain too. + expect(chain).toContain("section"); + + // Pre-scaled coords land in clickmap_events.pointer_*. SCALE_FACTOR=16. + expect(click.data.x_scaled).toBe(Math.round(100 / 16)); + expect(click.data.y_scaled).toBe(Math.round(200 / 16)); + expect(click.data.client_y_scaled).toBe(Math.round(200 / 16)); + expect(click.data.scale_factor).toBe(16); + expect(click.data.pointer_relative_x).toBeCloseTo(100 / window.innerWidth, 4); + expect(click.data.pointer_target_fixed).toBe(0); + + // Legacy CSS selector still emitted for back-compat. The builder prefers + // data-testid over id, so we assert against that anchor rather than #id. + expect(click.data.selector).toContain('data-testid="save"'); + expect(click.data.tag_name).toBe("button"); + } finally { + tracker.stop(); + } + }); + + it("ignores clicks inside the Hexclave dev tool", async () => { + vi.useFakeTimers(); + document.body.innerHTML = ` +
+ +
+ `; + + const sentBodies: string[] = []; + const tracker = new EventTracker({ + projectId: "internal", + sendBatch: async (body) => { + sentBodies.push(body); + return Result.ok(new Response()); + }, + }); + + try { + tracker.start(); + document.querySelector("button")?.dispatchEvent(new MouseEvent("click", { + bubbles: true, + clientX: 100, + clientY: 200, + })); + + await advancePastFlush(); + + expect(getSentEventTypes(sentBodies)).toMatchInlineSnapshot(` + [ + "$page-view", + ] + `); + } finally { + tracker.stop(); + } + }); + + it("flags pointer_target_fixed when the target sits under a fixed-position ancestor", async () => { + vi.useFakeTimers(); + document.body.innerHTML = ` +
+ +
+ `; + + const sentBodies: string[] = []; + const tracker = new EventTracker({ + projectId: "internal", + sendBatch: async (body) => { + sentBodies.push(body); + return Result.ok(new Response()); + }, + }); + + try { + tracker.start(); + document.querySelector("#cta")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await advancePastFlush(); + + const payload = JSON.parse(sentBodies[0] ?? "{}") as { events: { event_type: string, data: Record }[] }; + const click = payload.events.find((event) => event.event_type === "$click"); + expect(click?.data.pointer_target_fixed).toBe(1); + } finally { + tracker.stop(); + } + }); + it("captures client-side navigations when history is exposed as an accessor descriptor", async () => { vi.useFakeTimers(); diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts index 6f6cd3495d..421e398819 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts @@ -27,6 +27,130 @@ function hasHistoryMethods(value: unknown): value is { pushState: History["pushS return typeof value.pushState === "function" && typeof value.replaceState === "function"; } +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}`); +} + +// Pixel quantization factor for x/y/viewport in stored click events. Matches the +// SCALE_FACTOR used by the ClickHouse clickmap_events MV — keep them in sync. +const CLICKMAP_SCALE_FACTOR = 16; +const ELEMENTS_CHAIN_MAX_DEPTH = 8; +const ELEMENTS_CHAIN_TEXT_MAX = 80; +const ELEMENTS_CHAIN_ATTR_MAX = 200; +const HEXCLAVE_DEV_TOOL_ROOT_ID = "__hexclave-dev-tool-root"; +// Attributes we serialise into elements_chain. Mirrors the set PostHog persists: +// stable identifiers (id, data-testid), semantics (role, type, name, aria-label), +// and a few we expect downstream tooling to want to match against. +const ELEMENTS_CHAIN_ATTRS = [ + "id", + "data-testid", + "data-test-id", + "data-hexclave-id", + "name", + "type", + "role", + "aria-label", + "placeholder", + "title", +] as const; + +function escapeElementsChainValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function getElementClasses(element: Element): string[] { + const className = (element as HTMLElement).className; + if (typeof className !== "string" || className.trim() === "") { + return []; + } + return className.trim().split(/\s+/).filter(Boolean).slice(0, 4); +} + +function getNthChildIndex(element: Element): number | null { + const parent = element.parentElement; + if (parent == null) return null; + const index = Array.prototype.indexOf.call(parent.children, element); + return index >= 0 ? index + 1 : null; +} + +function getNthOfTypeIndex(element: Element): number | null { + const parent = element.parentElement; + if (parent == null) return null; + const tagName = element.tagName; + const siblings = Array.from(parent.children).filter((child) => child.tagName === tagName); + if (siblings.length <= 1) return null; + const index = siblings.indexOf(element); + return index >= 0 ? index + 1 : null; +} + +// Serialise one DOM element into a PostHog-compatible elements_chain segment. +// tag.class1.class2:nth-child="2":nth-of-type="1":text="Save":attr__id="save-btn":href="..." +// Segments are joined with ";" from leaf to root so downstream matchers can +// LIKE against any ancestor cheaply. +function serializeElementsChainSegment(element: Element): string { + const parts: string[] = []; + parts.push(element.tagName.toLowerCase()); + const classes = getElementClasses(element); + if (classes.length > 0) { + parts.push(`.${classes.join(".")}`); + } + const text = element.textContent.trim().replace(/\s+/g, " ").slice(0, ELEMENTS_CHAIN_TEXT_MAX); + const nthChild = getNthChildIndex(element); + const nthOfType = getNthOfTypeIndex(element); + const attrPairs: string[] = []; + if (nthChild != null) attrPairs.push(`nth-child="${nthChild}"`); + if (nthOfType != null) attrPairs.push(`nth-of-type="${nthOfType}"`); + if (text !== "") attrPairs.push(`text="${escapeElementsChainValue(text)}"`); + for (const attrName of ELEMENTS_CHAIN_ATTRS) { + const value = element.getAttribute(attrName); + if (value == null || value === "") continue; + attrPairs.push(`attr__${attrName}="${escapeElementsChainValue(value.slice(0, ELEMENTS_CHAIN_ATTR_MAX))}"`); + } + if (element.tagName === "A") { + const href = element.getAttribute("href"); + if (href != null && href !== "") { + attrPairs.push(`href="${escapeElementsChainValue(href.slice(0, ELEMENTS_CHAIN_ATTR_MAX))}"`); + } + } + if (attrPairs.length > 0) { + parts.push(`:${attrPairs.join(":")}`); + } + return parts.join(""); +} + +function buildElementsChain(element: Element): string { + const segments: string[] = []; + let current: Element | null = element; + let depth = 0; + while (current != null && depth < ELEMENTS_CHAIN_MAX_DEPTH && current !== document.documentElement) { + segments.push(serializeElementsChainSegment(current)); + current = current.parentElement; + depth += 1; + } + return segments.join(";"); +} + +function isPointerTargetFixed(element: Element): boolean { + let current: Element | null = element; + let depth = 0; + while (current != null && depth < ELEMENTS_CHAIN_MAX_DEPTH * 2) { + const style = window.getComputedStyle(current); + if (style.position === "fixed" || style.position === "sticky") { + return true; + } + current = current.parentElement; + depth += 1; + } + return false; +} + +function isInsideHexclaveDevTool(element: Element): boolean { + return element.closest(`#${cssEscapeIdent(HEXCLAVE_DEV_TOOL_ROOT_ID)}`) != null; +} + export type EventTrackerDeps = { projectId: string, sendBatch: (body: string, options: { keepalive: boolean }) => Promise>, @@ -166,21 +290,35 @@ export class EventTracker { let current: Element | null = element; let depth = 0; - while (current && depth < 5) { + while (current && depth < 8 && current !== document.documentElement) { let part = current.tagName.toLowerCase(); - if (current.id) { - part += `#${current.id}`; + const testId = current.getAttribute("data-testid") ?? current.getAttribute("data-test-id"); + if (testId != null && testId.trim() !== "") { + part += `[data-testid="${testId.replace(/"/g, '\\"')}"]`; + parts.unshift(part); + break; + } + if (current.id !== "") { + part += `#${cssEscapeIdent(current.id)}`; parts.unshift(part); break; } if (current.className && typeof current.className === "string") { - const classes = current.className.trim().split(/\s+/).filter(Boolean); + const classes = current.className.trim().split(/\s+/).filter(Boolean).slice(0, 4); if (classes.length > 0) { - part += `.${classes.join(".")}`; + part += `.${classes.map(cssEscapeIdent).join(".")}`; + } + } + const parent: Element | null = current.parentElement; + if (parent != null) { + const tagName = current.tagName; + const siblings = Array.from(parent.children).filter((child) => child.tagName === tagName); + if (siblings.length > 1) { + part += `:nth-of-type(${siblings.indexOf(current) + 1})`; } } parts.unshift(part); - current = current.parentElement; + current = parent; depth++; } @@ -201,6 +339,16 @@ export class EventTracker { private readonly _onClickCapture = (event: MouseEvent) => { const target = event.target; if (!(target instanceof Element)) return; + if (isInsideHexclaveDevTool(target)) return; + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const pointerTargetFixed = isPointerTargetFixed(target); + // Pre-scale at ingest so old + new rows land in identical buckets in CH. + const xScaled = Math.round(event.pageX / CLICKMAP_SCALE_FACTOR); + const yScaled = Math.round(event.pageY / CLICKMAP_SCALE_FACTOR); + const clientYScaled = Math.round(event.clientY / CLICKMAP_SCALE_FACTOR); + const relativeX = viewportWidth > 0 ? event.clientX / viewportWidth : 0; this._pushEvent({ event_type: "$click", @@ -210,12 +358,22 @@ export class EventTracker { text: target.textContent.trim().substring(0, 200), href: this._findNearestAnchorHref(target), selector: this._buildSelector(target), + elements_chain: buildElementsChain(target), + pointer_target_fixed: pointerTargetFixed ? 1 : 0, + url: window.location.href, + path: window.location.pathname, + title: document.title, x: event.clientX, y: event.clientY, page_x: event.pageX, page_y: event.pageY, - viewport_width: window.innerWidth, - viewport_height: window.innerHeight, + x_scaled: xScaled, + y_scaled: yScaled, + client_y_scaled: clientYScaled, + pointer_relative_x: relativeX, + viewport_width: viewportWidth, + viewport_height: viewportHeight, + scale_factor: CLICKMAP_SCALE_FACTOR, }, }); }; diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index b04e440791..3df3d22435 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -1,3 +1,4 @@ +import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@stackframe/stack-shared/dist/interface/crud/analytics"; import type { AdminGetSessionReplayChunkEventsResponse, AdminGetSessionReplayAllEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-replays"; import type { Transaction, TransactionType } from "@stackframe/stack-shared/dist/interface/crud/transactions"; @@ -153,6 +154,22 @@ export type StackAdminApp, queryAnalytics(options: AnalyticsQueryOptions): Promise, + getAnalyticsHeatmap(options: { + kind: "team_user_hour_of_week" | "session_replay_clicks", + member_user_ids?: string[], + route_path?: string, + route_regex?: string, + url_pattern?: string, + user_id?: string, + replay_id?: string, + device?: AnalyticsHeatmapDevice, + viewport_width_min?: number, + viewport_width_max?: number, + sampling?: number, + since: string, + until: string, + }): Promise, + createAnalyticsHeatmapToken(options: { origin: string }): Promise, listSessionReplays(options?: ListSessionReplaysOptions): Promise, getSessionReplay(sessionReplayId: string): Promise, From dbeb70c2877da8a3dc034f123fd31ce481cbbf9a Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 28 May 2026 19:13:12 -0700 Subject: [PATCH 02/10] fix(analytics): address heatmap PR review feedback - showHeatmap: handle token-creation failure (reset dialog) + call via runAsynchronouslyWithAlert so errors surface (greptile P1, vercel, cubic) - DEVICE_WIDTH_BUCKETS: use Map to avoid prototype-pollution lookup (greptile P2) - dedup formatClickhouseDateTimeParam/parseBoundedDateTime across heatmap routes by exporting from the internal route (greptile P2) - derive heatmap JWT expiry from HEATMAP_TOKEN_TTL_MS (cubic) - only report 'Invalid route regex' for actual ClickHouse regexp errors, via shared isClickhouseRegexpError helper (cubic, both routes) - generic top-elements error text instead of raw err.message (cubic) - dev-tool: add primary-button :hover style to prevent hover flash (cubic) - selector builder: emit the data-test-id attr name actually matched (cubic) --- .../app/api/latest/analytics/heatmap/route.ts | 17 ++------- .../internal/analytics/heatmap/route.ts | 37 +++++++++++++------ .../src/lib/analytics-heatmap-tokens.ts | 2 +- .../analytics/heatmaps/page-client.tsx | 20 ++++++++-- .../template/src/dev-tool/dev-tool-styles.ts | 7 ++++ .../apps/implementations/event-tracker.ts | 9 ++++- 6 files changed, 60 insertions(+), 32 deletions(-) diff --git a/apps/backend/src/app/api/latest/analytics/heatmap/route.ts b/apps/backend/src/app/api/latest/analytics/heatmap/route.ts index 1e2aa9749d..86b12c1aa5 100644 --- a/apps/backend/src/app/api/latest/analytics/heatmap/route.ts +++ b/apps/backend/src/app/api/latest/analytics/heatmap/route.ts @@ -8,6 +8,7 @@ import { HexclaveAssertionError, StatusError, captureError } from "@stackframe/s import { buildClickmapUrlLikePattern, clampClickmapSampling, + formatClickhouseDateTimeParam, getClickmapOriginFilter, getClickmapOriginParams, getClickmapRouteFilter, @@ -15,6 +16,8 @@ import { getClickmapUserAndReplayFilter, getClickmapViewportFilter, getDeviceViewportBucket, + isClickhouseRegexpError, + parseBoundedDateTime, } from "../../internal/analytics/heatmap/route"; const MAX_WINDOW_DAYS = 31; @@ -22,18 +25,6 @@ const ONE_DAY_MS = 24 * 60 * 60 * 1000; const ROUTE_LIMIT = 50; const ELEMENTS_CHAIN_LIMIT = 200; -function formatClickhouseDateTimeParam(date: Date): string { - return date.toISOString().slice(0, 19); -} - -function parseBoundedDateTime(value: string, name: string): Date { - const date = new Date(value); - if (!Number.isFinite(date.getTime())) { - throw new StatusError(StatusError.BadRequest, `Invalid ${name}`); - } - return date; -} - export const POST = createSmartRouteHandler({ metadata: { summary: "Get page heatmap data", @@ -199,7 +190,7 @@ export const POST = createSmartRouteHandler({ if (!(error instanceof ClickHouseError)) { throw error; } - if (body.route_regex != null && body.route_regex !== "") { + if (body.route_regex != null && body.route_regex !== "" && isClickhouseRegexpError(error)) { throw new StatusError(StatusError.BadRequest, "Invalid route regex"); } captureError("analytics-heatmap-clickhouse-fallback", new HexclaveAssertionError( diff --git a/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts b/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts index 611827d914..1ec3e94409 100644 --- a/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts +++ b/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts @@ -14,11 +14,11 @@ const LINKED_LIMIT = 25; const ELEMENTS_CHAIN_LIMIT = 100; const ONE_DAY_MS = 24 * 60 * 60 * 1000; -function formatClickhouseDateTimeParam(date: Date): string { +export function formatClickhouseDateTimeParam(date: Date): string { return date.toISOString().slice(0, 19); } -function parseBoundedDateTime(value: string, name: string): Date { +export function parseBoundedDateTime(value: string, name: string): Date { const date = new Date(value); if (!Number.isFinite(date.getTime())) { throw new StatusError(StatusError.BadRequest, `Invalid ${name}`); @@ -26,21 +26,29 @@ function parseBoundedDateTime(value: string, name: string): Date { return date; } +// ClickHouse raises a query-execution error when a user-supplied route regex +// fails to compile. Only those errors should be reported as a 400 "Invalid +// route regex"; unrelated ClickHouse failures must fall through to the generic +// service-unavailable path instead of being misattributed to the regex. +export function isClickhouseRegexpError(error: ClickHouseError): boolean { + return /regexp|regular expression|cannot compile/i.test(error.message); +} + // Device class buckets — kept as a back-compat shim for callers that still pass // `device`. Internally collapsed into viewport_width_min/max so the MV order key // (which leads with viewport_width) does the work instead of a multiIf scan. -const DEVICE_WIDTH_BUCKETS: Record = { - tv: { min: 1920, max: 65535 }, - widescreen: { min: 1440, max: 1919 }, - desktop: { min: 1200, max: 1439 }, - laptop: { min: 1024, max: 1199 }, - tablet: { min: 768, max: 1023 }, - mobile: { min: 0, max: 767 }, -}; +const DEVICE_WIDTH_BUCKETS = new Map([ + ["tv", { min: 1920, max: 65535 }], + ["widescreen", { min: 1440, max: 1919 }], + ["desktop", { min: 1200, max: 1439 }], + ["laptop", { min: 1024, max: 1199 }], + ["tablet", { min: 768, max: 1023 }], + ["mobile", { min: 0, max: 767 }], +]); export function getDeviceViewportBucket(device: string | undefined): { min: number, max: number } | null { if (device == null || device === "") return null; - return DEVICE_WIDTH_BUCKETS[device] ?? null; + return DEVICE_WIDTH_BUCKETS.get(device) ?? null; } // Translate a PostHog-style URL pattern with `*` wildcards into a SQL LIKE @@ -415,7 +423,12 @@ export const POST = createSmartRouteHandler({ if (!(error instanceof ClickHouseError)) { throw error; } - if (body.kind === "session_replay_clicks" && body.route_regex != null && body.route_regex !== "") { + if ( + body.kind === "session_replay_clicks" && + body.route_regex != null && + body.route_regex !== "" && + isClickhouseRegexpError(error) + ) { throw new StatusError(StatusError.BadRequest, "Invalid route regex"); } captureError("internal-analytics-heatmap-clickhouse-fallback", new HexclaveAssertionError( diff --git a/apps/backend/src/lib/analytics-heatmap-tokens.ts b/apps/backend/src/lib/analytics-heatmap-tokens.ts index 21a6e9a897..585161e412 100644 --- a/apps/backend/src/lib/analytics-heatmap-tokens.ts +++ b/apps/backend/src/lib/analytics-heatmap-tokens.ts @@ -59,7 +59,7 @@ export async function createAnalyticsHeatmapToken(options: { const token = await signJWT({ issuer: HEATMAP_TOKEN_ISSUER, audience: HEATMAP_TOKEN_AUDIENCE, - expirationTime: "24h", + expirationTime: `${HEATMAP_TOKEN_TTL_MS / 1000}s`, payload: { kind: HEATMAP_TOKEN_KIND, scope: HEATMAP_TOKEN_SCOPE, diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx index 6d4bbfde6c..9c73916b17 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx @@ -33,6 +33,7 @@ import { } from "@stackframe/dashboard-ui-components"; import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import { ArrowRight, GlobeHemisphereWest } from "@phosphor-icons/react"; import { useEffect, useMemo, useState } from "react"; @@ -163,7 +164,8 @@ function TopElementsPreview(props: { }) .catch((err: unknown) => { if (cancelled) return; - setError(err instanceof Error ? err.message : "Failed to load top elements."); + // Avoid surfacing raw error messages to users; show a safe generic message. + setError("Failed to load top elements."); setData(null); }) .finally(() => { @@ -406,7 +408,17 @@ export default function PageClient() { setSelectedOrigin(origin); setToken(null); setDialogOpen(true); - const created = await adminApp.createAnalyticsHeatmapToken({ origin: origin.origin }); + let created: AnalyticsHeatmapTokenResponse; + try { + created = await adminApp.createAnalyticsHeatmapToken({ origin: origin.origin }); + } catch (error) { + // Token creation failed (network error, expired session, invalid origin, + // etc.); close the dialog so it doesn't hang on "Creating..." and let + // runAsynchronouslyWithAlert surface the error to the user. + setToken(null); + setDialogOpen(false); + throw error; + } setToken(created); const installedInCurrentTab = installHeatmapTokenForCurrentOrigin(created, adminApp.projectId); try { @@ -435,7 +447,7 @@ export default function PageClient() { setCustomOrigin(event.target.value)} placeholder="http://localhost:3000" />
- @@ -462,7 +474,7 @@ export default function PageClient() {
- diff --git a/packages/template/src/dev-tool/dev-tool-styles.ts b/packages/template/src/dev-tool/dev-tool-styles.ts index b5295b7cfd..bf0c5f1fbe 100644 --- a/packages/template/src/dev-tool/dev-tool-styles.ts +++ b/packages/template/src/dev-tool/dev-tool-styles.ts @@ -2827,6 +2827,13 @@ export const devToolCSS = ` color: white; } + .stack-devtool .sdt-hm-btn-primary:hover { + background: var(--sdt-accent); + border-color: var(--sdt-accent); + color: white; + transition: none; + } + .stack-devtool .sdt-hm-stats { flex: 1; min-width: 0; diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts index 421e398819..6549fdaf5f 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts @@ -292,9 +292,14 @@ export class EventTracker { while (current && depth < 8 && current !== document.documentElement) { let part = current.tagName.toLowerCase(); - const testId = current.getAttribute("data-testid") ?? current.getAttribute("data-test-id"); + let testIdAttr = "data-testid"; + let testId = current.getAttribute("data-testid"); + if (testId == null) { + testIdAttr = "data-test-id"; + testId = current.getAttribute("data-test-id"); + } if (testId != null && testId.trim() !== "") { - part += `[data-testid="${testId.replace(/"/g, '\\"')}"]`; + part += `[${testIdAttr}="${testId.replace(/"/g, '\\"')}"]`; parts.unshift(part); break; } From 0a21fbaa080fde7b876b21a7a6f94239733d41ff Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 28 May 2026 19:20:34 -0700 Subject: [PATCH 03/10] fix(analytics): pass async showHeatmap directly to Button Button already wraps onClick in useAsyncCallback (loading/disabled tracking) and runAsynchronouslyWithAlert (error surfacing), so the explicit wrapper bypassed loading state. Pass the async handler directly per the established Button idiom (cubic). --- .../projects/[projectId]/analytics/heatmaps/page-client.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx index 9c73916b17..89ab977563 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx @@ -33,7 +33,6 @@ import { } from "@stackframe/dashboard-ui-components"; import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; -import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import { ArrowRight, GlobeHemisphereWest } from "@phosphor-icons/react"; import { useEffect, useMemo, useState } from "react"; @@ -447,7 +446,7 @@ export default function PageClient() { setCustomOrigin(event.target.value)} placeholder="http://localhost:3000" /> - @@ -474,7 +473,7 @@ export default function PageClient() { - From a11d27ac8b223d53deedae817706d6b2dca056cd Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 28 May 2026 19:59:52 -0700 Subject: [PATCH 04/10] fix(analytics): address CodeRabbit review feedback - heatmap token verify: validate JWT audience and narrow the catch so unexpected backend faults aren't misreported as auth failures - heatmaps page: keep filter controls/grid mounted on fetch error instead of replacing them with only an alert - dev-tool: remove return from finally block (noUnsafeFinally) - dev-tool: give the heatmaps tab a teardown lifecycle so leaving it removes global blockers/observer/polling instead of caching the pane - event-tracker: escape '.'/':' in class tokens of elements_chain and decode them in the overlay parser so Tailwind variant classes round-trip - SDK: expose camelCase options for getAnalyticsHeatmap and map to snake_case in the impl (aligns with listSessionReplays) --- .../src/lib/analytics-heatmap-tokens.ts | 24 ++-- .../analytics/heatmaps/page-client.tsx | 107 +++++++++--------- .../teams/[teamId]/team-analytics.tsx | 2 +- .../template/src/dev-tool/dev-tool-core.ts | 60 ++++++++-- .../apps/implementations/admin-app-impl.ts | 32 ++++-- .../apps/implementations/event-tracker.ts | 11 +- .../stack-app/apps/interfaces/admin-app.ts | 16 +-- 7 files changed, 165 insertions(+), 87 deletions(-) diff --git a/apps/backend/src/lib/analytics-heatmap-tokens.ts b/apps/backend/src/lib/analytics-heatmap-tokens.ts index 585161e412..80dc1ce1fc 100644 --- a/apps/backend/src/lib/analytics-heatmap-tokens.ts +++ b/apps/backend/src/lib/analytics-heatmap-tokens.ts @@ -4,6 +4,8 @@ import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-field import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { signJWT, verifyJWT } from "@stackframe/stack-shared/dist/utils/jwt"; import { yupValidate } from "@stackframe/stack-shared/dist/schema-fields"; +import { JOSEError } from "jose/errors"; +import { ValidationError } from "yup"; const HEATMAP_TOKEN_ISSUER = "hexclave:analytics:heatmap"; const HEATMAP_TOKEN_AUDIENCE = "hexclave:analytics:heatmap-overlay"; @@ -78,13 +80,21 @@ export async function verifyAnalyticsHeatmapToken(options: { const origin = normalizeAnalyticsHeatmapOrigin(options.origin); let payload: AnalyticsHeatmapTokenPayload; try { - payload = await yupValidate( - AnalyticsHeatmapTokenPayloadSchema, - await verifyJWT({ allowedIssuers: [HEATMAP_TOKEN_ISSUER], jwt: options.token }), - { abortEarly: false }, - ); - } catch { - throw new StatusError(StatusError.Unauthorized, "Invalid or expired heatmap token"); + const verified = await verifyJWT({ allowedIssuers: [HEATMAP_TOKEN_ISSUER], jwt: options.token }); + // verifyJWT only constrains the issuer, so also require the audience to match + // — otherwise a validly-signed token minted for a different audience could pass. + if (verified.aud !== HEATMAP_TOKEN_AUDIENCE) { + throw new StatusError(StatusError.Unauthorized, "Invalid or expired heatmap token"); + } + payload = await yupValidate(AnalyticsHeatmapTokenPayloadSchema, verified, { abortEarly: false }); + } catch (error) { + // Only expected JWT/validation failures are auth errors; rethrow anything + // unexpected (e.g. backend faults) so they aren't misreported as bad credentials. + if (error instanceof StatusError) throw error; + if (error instanceof JOSEError || error instanceof ValidationError) { + throw new StatusError(StatusError.Unauthorized, "Invalid or expired heatmap token"); + } + throw error; } if (payload.origin !== origin) { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx index 89ab977563..bcb22d853a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx @@ -149,7 +149,7 @@ function TopElementsPreview(props: { }; const trimmedPattern = urlPattern.trim(); if (trimmedPattern !== "") { - options.url_pattern = trimmedPattern; + options.urlPattern = trimmedPattern; } if (device !== "all") { options.device = device; @@ -221,60 +221,59 @@ function TopElementsPreview(props: { )} - {error != null ? ( - {error} - ) : ( - ( -
- - - setUrlPattern(event.target.value)} - placeholder="/products/*" - className="h-8 w-full text-xs sm:ml-auto sm:w-[220px]" - /> -
- )} - footer={false} - fillHeight={false} - rowHeight="auto" - estimatedRowHeight={56} - overscan={8} - paginationMode="infinite" - emptyState={ -
- No clicks captured in this window. -
- } - /> + {error != null && ( + {error} )} + ( +
+ + + setUrlPattern(event.target.value)} + placeholder="/products/*" + className="h-8 w-full text-xs sm:ml-auto sm:w-[220px]" + /> +
+ )} + footer={false} + fillHeight={false} + rowHeight="auto" + estimatedRowHeight={56} + overscan={8} + paginationMode="infinite" + emptyState={ +
+ No clicks captured in this window. +
+ } + /> ); } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/team-analytics.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/team-analytics.tsx index c8efeddb81..80829832f0 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/team-analytics.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/teams/[teamId]/team-analytics.tsx @@ -302,7 +302,7 @@ export function TeamAnalyticsSection({ team }: { team: ServerTeam }) { runQuery(DAU_QUERY, baseParams), stackAdminApp.getAnalyticsHeatmap({ kind: "team_user_hour_of_week", - member_user_ids: memberIds, + memberUserIds: memberIds, since: heatmapSince.toISOString(), until: now.toISOString(), }), diff --git a/packages/template/src/dev-tool/dev-tool-core.ts b/packages/template/src/dev-tool/dev-tool-core.ts index 655c5af572..901ff6a834 100644 --- a/packages/template/src/dev-tool/dev-tool-core.ts +++ b/packages/template/src/dev-tool/dev-tool-core.ts @@ -1901,6 +1901,30 @@ function parseElementsChain(chain: string): ElementsChainSegment[] { return segments.map(parseElementsChainSegment).filter((segment): segment is ElementsChainSegment => segment != null); } +// Split a string on unescaped occurrences of `.`, unescaping `\.`, `\:` and `\\` +// back to their literal characters. Used to recover class tokens from the +// dot-joined segment prefix, which the event tracker escapes during serialization. +function splitEscapedDots(input: string): string[] { + const out: string[] = []; + let cur = ''; + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + if (ch === '\\' && i + 1 < input.length) { + cur += input[i + 1]; + i += 1; + continue; + } + if (ch === '.') { + out.push(cur); + cur = ''; + continue; + } + cur += ch; + } + out.push(cur); + return out; +} + function parseElementsChainSegment(segment: string): ElementsChainSegment | null { const trimmed = segment.trim(); if (trimmed === '') return null; @@ -1926,7 +1950,7 @@ function parseElementsChainSegment(segment: string): ElementsChainSegment | null const prefix = trimmed.slice(0, prefixEnd); const rest = trimmed.slice(prefixEnd); - const prefixParts = prefix.split('.'); + const prefixParts = splitEscapedDots(prefix); const tag = prefixParts[0].trim().toLowerCase(); if (tag === '') return null; const classes = prefixParts.slice(1).map((c) => c.trim()).filter((c) => c !== ''); @@ -3033,11 +3057,10 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe serverHeatmapError = error instanceof Error ? error.message : 'Failed to load heatmap data'; } } finally { - if (!isLatestRequest()) { - return; + if (isLatestRequest()) { + loadingServerHeatmap = false; + render(); } - loadingServerHeatmap = false; - render(); } } @@ -3620,6 +3643,22 @@ function createPanel( } } + let heatmapsCleanup: (() => void) | null = null; + // The heatmaps tab installs global click/key blockers, an overlay root, a + // MutationObserver, and background polling. Unlike other panes it must not be + // cached-and-hidden: tear it down (running its cleanup) whenever we leave it, + // so the page isn't left interaction-blocked with work still running. + function teardownHeatmapsPane() { + const pane = mountedPanes.get('heatmaps'); + if (pane == null) return; + if (heatmapsCleanup != null) { + heatmapsCleanup(); + heatmapsCleanup = null; + } + pane.remove(); + mountedPanes.delete('heatmaps'); + } + function getOrCreatePane(tabId: TabId): HTMLElement { if (mountedPanes.has(tabId)) { return mountedPanes.get(tabId)!; @@ -3634,11 +3673,14 @@ function createPanel( break; } case 'heatmaps': { - mountTab(pane, createHeatmapsTab(app, () => { + const result = createHeatmapsTab(app, () => { state.update({ activeTab: lastNonHeatmapTab }); applyPanelMode(lastNonHeatmapTab, { animate: true }); showTab(lastNonHeatmapTab); - })); + }); + pane.appendChild(result.element); + // Tracked separately from `cleanups` so it can run on tab-switch, not just unmount. + heatmapsCleanup = result.cleanup ?? null; break; } case 'customize': { @@ -3668,6 +3710,9 @@ function createPanel( } function showTab(tabId: TabId) { + if (tabId !== 'heatmaps') { + teardownHeatmapsPane(); + } const pane = getOrCreatePane(tabId); tabBar.setActive(tabId); for (const [, p] of mountedPanes) { @@ -3732,6 +3777,7 @@ function createPanel( if (panelAnimationTimeout !== null) { clearTimeout(panelAnimationTimeout); } + teardownHeatmapsPane(); for (const fn of cleanups) fn(); }, }; diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 8b3e97edb2..c7a8667003 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -1170,20 +1170,34 @@ export class _StackAdminAppImplIncomplete { - return await this._interface.getAnalyticsHeatmap(options); + return await this._interface.getAnalyticsHeatmap({ + kind: options.kind, + member_user_ids: options.memberUserIds, + route_path: options.routePath, + route_regex: options.routeRegex, + url_pattern: options.urlPattern, + user_id: options.userId, + replay_id: options.replayId, + device: options.device, + viewport_width_min: options.viewportWidthMin, + viewport_width_max: options.viewportWidthMax, + sampling: options.sampling, + since: options.since, + until: options.until, + }); } async createAnalyticsHeatmapToken(options: { origin: string }): Promise { diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts index 6549fdaf5f..19767f09db 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts @@ -61,6 +61,15 @@ function escapeElementsChainValue(value: string): string { return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } +// Class tokens are written into the unquoted, dot-joined prefix of a segment, so +// any "." or ":" inside a class (e.g. Tailwind variants like `md:hover:bg-blue-500` +// or arbitrary values like `w-[1.5rem]`) must be escaped to round-trip through the +// overlay parser, which splits the prefix on unescaped "." and the segment on +// unescaped ":". +function escapeElementsChainClass(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/\./g, "\\.").replace(/:/g, "\\:"); +} + function getElementClasses(element: Element): string[] { const className = (element as HTMLElement).className; if (typeof className !== "string" || className.trim() === "") { @@ -95,7 +104,7 @@ function serializeElementsChainSegment(element: Element): string { parts.push(element.tagName.toLowerCase()); const classes = getElementClasses(element); if (classes.length > 0) { - parts.push(`.${classes.join(".")}`); + parts.push(`.${classes.map(escapeElementsChainClass).join(".")}`); } const text = element.textContent.trim().replace(/\s+/g, " ").slice(0, ELEMENTS_CHAIN_TEXT_MAX); const nthChild = getNthChildIndex(element); diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index 3df3d22435..783880612b 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -156,15 +156,15 @@ export type StackAdminApp, getAnalyticsHeatmap(options: { kind: "team_user_hour_of_week" | "session_replay_clicks", - member_user_ids?: string[], - route_path?: string, - route_regex?: string, - url_pattern?: string, - user_id?: string, - replay_id?: string, + memberUserIds?: string[], + routePath?: string, + routeRegex?: string, + urlPattern?: string, + userId?: string, + replayId?: string, device?: AnalyticsHeatmapDevice, - viewport_width_min?: number, - viewport_width_max?: number, + viewportWidthMin?: number, + viewportWidthMax?: number, sampling?: number, since: string, until: string, From f80b8e4f64a45b7c727797a63d0c717989b85015 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 28 May 2026 20:34:34 -0700 Subject: [PATCH 05/10] test(e2e): update analytics-query grant/table snapshots for clickmap_events The new default.clickmap_events view (added to the limited_user grant + row-policy set) now appears in SHOW TABLES, SHOW GRANTS, and system.tables. Update the four affected inline snapshots accordingly. The metrics_result and email snapshots that also fail are inherited from the base branch (codex/analytics-overview-filters) and are out of scope here. --- .../tests/backend/endpoints/api/v1/analytics-query.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts index 59bf43cce2..156feb135b 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/analytics-query.test.ts @@ -588,6 +588,7 @@ it("has limited grants", async ({ expect }) => { { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "REVOKE TABLE ENGINE ON SQLite FROM limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "REVOKE TABLE ENGINE ON URL FROM limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW DATABASES ON default.* TO limited_user" }, + { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.clickmap_events TO limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.connected_accounts TO limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.contact_channels TO limited_user" }, { "GRANTS WITH IMPLICIT FINAL FORMAT JSONEachRow": "GRANT SHOW TABLES, SHOW COLUMNS, SELECT ON default.email_outboxes TO limited_user" }, @@ -637,6 +638,10 @@ it("can see only some tables", async ({ expect }) => { "status": 200, "body": { "result": [ + { + "database": "default", + "name": "clickmap_events", + }, { "database": "default", "name": "connected_accounts", @@ -702,6 +707,7 @@ it("SHOW TABLES should have the correct tables", async ({ expect }) => { "status": 200, "body": { "result": [ + { "name": "clickmap_events" }, { "name": "connected_accounts" }, { "name": "contact_channels" }, { "name": "email_outboxes" }, @@ -1194,6 +1200,7 @@ it("shows grants", async ({ expect }) => { "status": 200, "body": { "result": [ + { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.clickmap_events TO limited_user" }, { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.connected_accounts TO limited_user" }, { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.contact_channels TO limited_user" }, { "GRANTS FORMAT JSONEachRow": "GRANT SELECT ON default.email_outboxes TO limited_user" }, From 1ccde489bbfcecca1ff9a5e8905475b25a1ac3a0 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Fri, 29 May 2026 11:08:32 -0700 Subject: [PATCH 06/10] refactor(analytics): streamline heatmap query handling and enhance overlay integration - Consolidated heatmap query logic into shared utility functions for better maintainability. - Updated heatmap overlay token handling to use consistent storage keys across components. - Improved error handling for ClickHouse queries, ensuring proper status reporting. - Added comprehensive tests for new query utilities and heatmap overlay functionality. - Refactored related components to utilize the new utility functions, enhancing code clarity and reducing duplication. --- .../app/api/latest/analytics/heatmap/route.ts | 182 ++---- .../internal/analytics/heatmap/route.ts | 530 +++++------------- .../analytics-clickmap-query.test.ts} | 11 +- .../src/lib/analytics-clickmap-query.ts | 404 +++++++++++++ .../analytics/heatmaps/page-client.tsx | 36 +- .../src/interface/admin-interface.ts | 4 +- .../src/interface/admin-metrics.ts | 19 + .../src/utils/analytics-heatmap-overlay.tsx | 26 + packages/stack-shared/src/utils/dev-tool.tsx | 22 + packages/stack-shared/src/utils/dom.tsx | 12 + .../stack-shared/src/utils/elements-chain.tsx | 349 ++++++++++++ .../template/src/dev-tool/dev-tool-core.ts | 353 +++--------- .../apps/implementations/admin-app-impl.ts | 18 +- .../apps/implementations/event-tracker.ts | 116 +--- .../stack-app/apps/interfaces/admin-app.ts | 18 +- 15 files changed, 1108 insertions(+), 992 deletions(-) rename apps/backend/src/{app/api/latest/internal/analytics/heatmap/route.test.ts => lib/analytics-clickmap-query.test.ts} (91%) create mode 100644 apps/backend/src/lib/analytics-clickmap-query.ts create mode 100644 packages/stack-shared/src/utils/analytics-heatmap-overlay.tsx create mode 100644 packages/stack-shared/src/utils/dev-tool.tsx create mode 100644 packages/stack-shared/src/utils/elements-chain.tsx diff --git a/apps/backend/src/app/api/latest/analytics/heatmap/route.ts b/apps/backend/src/app/api/latest/analytics/heatmap/route.ts index 86b12c1aa5..d900103c9d 100644 --- a/apps/backend/src/app/api/latest/analytics/heatmap/route.ts +++ b/apps/backend/src/app/api/latest/analytics/heatmap/route.ts @@ -1,24 +1,15 @@ -import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse"; +import { + type ClickmapClicksQueryResult, + parseBoundedDateTime, + runClickmapClicksQuery, + throwClickhouseHeatmapError, +} from "@/lib/analytics-clickmap-query"; import { verifyAnalyticsHeatmapToken } from "@/lib/analytics-heatmap-tokens"; +import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { ClickHouseError } from "@clickhouse/client"; import { AnalyticsHeatmapResponseBodySchema, type AnalyticsHeatmapResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { HexclaveAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; -import { - buildClickmapUrlLikePattern, - clampClickmapSampling, - formatClickhouseDateTimeParam, - getClickmapOriginFilter, - getClickmapOriginParams, - getClickmapRouteFilter, - getClickmapSystemElementFilter, - getClickmapUserAndReplayFilter, - getClickmapViewportFilter, - getDeviceViewportBucket, - isClickhouseRegexpError, - parseBoundedDateTime, -} from "../../internal/analytics/heatmap/route"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; const MAX_WINDOW_DAYS = 31; const ONE_DAY_MS = 24 * 60 * 60 * 1000; @@ -79,143 +70,46 @@ export const POST = createSmartRouteHandler({ throw new StatusError(StatusError.BadRequest, `Heatmap window cannot exceed ${MAX_WINDOW_DAYS} days`); } - const deviceBucket = getDeviceViewportBucket(body.device); - const viewportMin = body.viewport_width_min ?? deviceBucket?.min; - const viewportMax = body.viewport_width_max ?? deviceBucket?.max; - const urlPatternLike = buildClickmapUrlLikePattern(body.url_pattern); - const samplingPct = Math.max(1, Math.round(clampClickmapSampling(body.sampling) * 100)); - const samplingScale = 100 / samplingPct; - const samplingClause = samplingPct < 100 - ? "AND intHash32(toUInt32(toUnixTimestamp(event_at)) + cityHash64(coalesce(toString(user_id), ''))) % 100 < {samplingPct:UInt32}" - : ""; - const routeFilter = getClickmapRouteFilter(body.route_path, body.route_regex, urlPatternLike); - const userAndReplayFilter = getClickmapUserAndReplayFilter(body.user_id, body.replay_id); - const originFilter = getClickmapOriginFilter(); - const viewportFilter = getClickmapViewportFilter(viewportMin, viewportMax); - const systemElementFilter = getClickmapSystemElementFilter(); - const params: Record = { - projectId: heatmapToken.project_id, - branchId: heatmapToken.branch_id, - ...getClickmapOriginParams(heatmapToken.origin), - since: formatClickhouseDateTimeParam(since), - until: formatClickhouseDateTimeParam(until), - routeLimit: ROUTE_LIMIT, - elementsChainLimit: ELEMENTS_CHAIN_LIMIT, - samplingPct, - ...(body.route_path ? { routePath: body.route_path } : {}), - ...(body.route_regex ? { routeRegex: body.route_regex } : {}), - ...(urlPatternLike != null ? { urlPatternLike } : {}), - ...(body.user_id ? { userId: body.user_id } : {}), - ...(body.replay_id ? { replayId: body.replay_id } : {}), - ...(viewportMin != null ? { viewportWidthMin: viewportMin } : {}), - ...(viewportMax != null ? { viewportWidthMax: viewportMax } : {}), - }; - const sharedWhere = ` - project_id = {projectId:String} - AND branch_id = {branchId:String} - AND event_at >= {since:DateTime} - AND event_at < {until:DateTime} - ${originFilter} - ${routeFilter} - ${viewportFilter} - ${systemElementFilter} - ${samplingClause} - `; - const client = getClickhouseAdminClientForMetrics(); - let routes: { path: string, clicks: number | string, users: number | string, replays: number | string }[]; - let selectors: { selector: string, clicks: number | string }[]; - let elements: { elements_chain: string, elements_text: string, tag_name: string, href: string | null, clicks: number | string }[]; + let result: ClickmapClicksQueryResult; try { - const [routesResult, selectorsResult, elementsResult] = await Promise.all([ - client.query({ - query: ` - SELECT - path, - count() AS clicks, - uniqExactIf(assumeNotNull(user_id), user_id IS NOT NULL) AS users, - uniqExactIf(assumeNotNull(session_replay_id), session_replay_id IS NOT NULL) AS replays - FROM analytics_internal.clickmap_events - WHERE ${sharedWhere} - AND path != '' - ${userAndReplayFilter} - GROUP BY path - ORDER BY clicks DESC - LIMIT {routeLimit:UInt32} - `, - query_params: params, - format: "JSONEachRow", - }), - client.query({ - query: ` - SELECT - nullIf(selector, '') AS selector, - count() AS clicks - FROM analytics_internal.clickmap_events - WHERE ${sharedWhere} - AND selector != '' - ${userAndReplayFilter} - GROUP BY selector - ORDER BY clicks DESC - LIMIT {routeLimit:UInt32} - `, - query_params: params, - format: "JSONEachRow", - }), - client.query({ - query: ` - SELECT - elements_chain, - any(elements_text) AS elements_text, - any(tag_name) AS tag_name, - any(href) AS href, - count() AS clicks - FROM analytics_internal.clickmap_events - WHERE ${sharedWhere} - AND elements_chain != '' - ${userAndReplayFilter} - GROUP BY elements_chain - ORDER BY clicks DESC - LIMIT {elementsChainLimit:UInt32} - `, - query_params: params, - format: "JSONEachRow", - }), - ]); - - routes = await routesResult.json(); - selectors = await selectorsResult.json(); - elements = await elementsResult.json(); + result = await runClickmapClicksQuery(client, { + projectId: heatmapToken.project_id, + branchId: heatmapToken.branch_id, + since, + until, + origin: heatmapToken.origin, + routePath: body.route_path, + routeRegex: body.route_regex, + urlPattern: body.url_pattern, + userId: body.user_id, + replayId: body.replay_id, + device: body.device, + viewportWidthMin: body.viewport_width_min, + viewportWidthMax: body.viewport_width_max, + sampling: body.sampling, + routeLimit: ROUTE_LIMIT, + elementsChainLimit: ELEMENTS_CHAIN_LIMIT, + }); } catch (error) { - if (!(error instanceof ClickHouseError)) { - throw error; - } - if (body.route_regex != null && body.route_regex !== "" && isClickhouseRegexpError(error)) { - throw new StatusError(StatusError.BadRequest, "Invalid route regex"); - } - captureError("analytics-heatmap-clickhouse-fallback", new HexclaveAssertionError( - "Failed to load analytics heatmap due to ClickHouse query failure.", - { cause: error, projectId: heatmapToken.project_id, branchId: heatmapToken.branch_id }, - )); - throw new StatusError(StatusError.ServiceUnavailable, "Analytics heatmap is temporarily unavailable."); + throwClickhouseHeatmapError(error, { + captureLabel: "analytics-heatmap-clickhouse-fallback", + routeRegex: body.route_regex, + context: { projectId: heatmapToken.project_id, branchId: heatmapToken.branch_id }, + }); } - const scaleCount = (value: number | string) => Math.round(Number(value) * samplingScale); + // The public overlay only consumes routes/selectors/elements; per-user and + // per-replay aggregates are intentionally not fetched here (no linkedLimit). const responseBody: AnalyticsHeatmapResponse = { kind: "session_replay_clicks", cells: [], - sampling: samplingPct / 100, - routes: routes.map((row) => ({ path: row.path, clicks: scaleCount(row.clicks), users: scaleCount(row.users), replays: scaleCount(row.replays) })), + sampling: result.samplingPct / 100, + routes: result.routes, users: [], replays: [], - selectors: selectors.map((row) => ({ selector: row.selector, clicks: scaleCount(row.clicks) })), - elements: elements.map((row) => ({ - elements_chain: row.elements_chain, - elements_text: row.elements_text, - tag_name: row.tag_name, - href: row.href, - clicks: scaleCount(row.clicks), - })), + selectors: result.selectors, + elements: result.elements, }; return { diff --git a/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts b/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts index 1ec3e94409..d903d494af 100644 --- a/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts +++ b/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts @@ -1,10 +1,19 @@ +import { + buildHourOfWeekHeatmapCells, + type ClickmapClicksQueryResult, + formatClickhouseDateTimeParam, + parseBoundedDateTime, + runClickmapClicksQuery, + throwClickhouseHeatmapError, +} from "@/lib/analytics-clickmap-query"; import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse"; +import type { Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { AnalyticsHeatmapResponseBodySchema } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { AnalyticsHeatmapResponseBodySchema, type AnalyticsHeatmapResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { ClickHouseError } from "@clickhouse/client"; -import { HexclaveAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import yup from "yup"; import { userFullInclude, userPrismaToCrud } from "../../../users/crud"; const MAX_TEAM_MEMBER_IDS = 500; @@ -14,139 +23,143 @@ const LINKED_LIMIT = 25; const ELEMENTS_CHAIN_LIMIT = 100; const ONE_DAY_MS = 24 * 60 * 60 * 1000; -export function formatClickhouseDateTimeParam(date: Date): string { - return date.toISOString().slice(0, 19); +const heatmapRequestBodySchema = yupObject({ + kind: yupString().oneOf(["team_user_hour_of_week", "session_replay_clicks"]).defined(), + member_user_ids: yupArray(yupString().defined()).optional().default([]).max(MAX_TEAM_MEMBER_IDS), + route_path: yupString().optional(), + route_regex: yupString().optional(), + url_pattern: yupString().optional(), + user_id: yupString().optional(), + replay_id: yupString().optional(), + device: yupString().oneOf(["tv", "widescreen", "desktop", "laptop", "tablet", "mobile"]).optional(), + viewport_width_min: yupNumber().integer().min(0).max(65535).optional(), + viewport_width_max: yupNumber().integer().min(0).max(65535).optional(), + sampling: yupNumber().min(0).max(1).optional(), + since: yupString().defined(), + until: yupString().defined(), +}).defined(); + +type HeatmapRequestBody = yup.InferType; + +function emptyHeatmapResponse(kind: AnalyticsHeatmapResponse["kind"], cells: AnalyticsHeatmapResponse["cells"]): AnalyticsHeatmapResponse { + return { kind, cells, sampling: 1, routes: [], users: [], replays: [], selectors: [], elements: [] }; } -export function parseBoundedDateTime(value: string, name: string): Date { - const date = new Date(value); - if (!Number.isFinite(date.getTime())) { - throw new StatusError(StatusError.BadRequest, `Invalid ${name}`); +async function handleClickHeatmap(tenancy: Tenancy, body: HeatmapRequestBody, since: Date, until: Date): Promise { + const client = getClickhouseAdminClientForMetrics(); + let result: ClickmapClicksQueryResult; + try { + result = await runClickmapClicksQuery(client, { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + since, + until, + routePath: body.route_path, + routeRegex: body.route_regex, + urlPattern: body.url_pattern, + userId: body.user_id, + replayId: body.replay_id, + device: body.device, + viewportWidthMin: body.viewport_width_min, + viewportWidthMax: body.viewport_width_max, + sampling: body.sampling, + routeLimit: ROUTE_LIMIT, + elementsChainLimit: ELEMENTS_CHAIN_LIMIT, + linkedLimit: LINKED_LIMIT, + }); + } catch (error) { + throwClickhouseHeatmapError(error, { + captureLabel: "internal-analytics-heatmap-clickhouse-fallback", + routeRegex: body.route_regex, + context: { projectId: tenancy.project.id, branchId: tenancy.branchId, kind: body.kind }, + }); } - return date; -} - -// ClickHouse raises a query-execution error when a user-supplied route regex -// fails to compile. Only those errors should be reported as a 400 "Invalid -// route regex"; unrelated ClickHouse failures must fall through to the generic -// service-unavailable path instead of being misattributed to the regex. -export function isClickhouseRegexpError(error: ClickHouseError): boolean { - return /regexp|regular expression|cannot compile/i.test(error.message); -} - -// Device class buckets — kept as a back-compat shim for callers that still pass -// `device`. Internally collapsed into viewport_width_min/max so the MV order key -// (which leads with viewport_width) does the work instead of a multiIf scan. -const DEVICE_WIDTH_BUCKETS = new Map([ - ["tv", { min: 1920, max: 65535 }], - ["widescreen", { min: 1440, max: 1919 }], - ["desktop", { min: 1200, max: 1439 }], - ["laptop", { min: 1024, max: 1199 }], - ["tablet", { min: 768, max: 1023 }], - ["mobile", { min: 0, max: 767 }], -]); - -export function getDeviceViewportBucket(device: string | undefined): { min: number, max: number } | null { - if (device == null || device === "") return null; - return DEVICE_WIDTH_BUCKETS.get(device) ?? null; -} - -// Translate a PostHog-style URL pattern with `*` wildcards into a SQL LIKE -// pattern, escaping the underlying `_` / `%` / `\` so they're treated literally. -// Empty string disables the filter. -export function buildClickmapUrlLikePattern(urlPattern: string | undefined): string | null { - if (urlPattern == null || urlPattern === "") return null; - const escaped = urlPattern.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); - return escaped.replace(/\*/g, "%"); -} - -export function getClickmapRouteFilter(routePath: string | undefined, routeRegex: string | undefined, urlPatternLike: string | null): string { - if (routeRegex != null && routeRegex !== "") { - return "AND match(path, {routeRegex:String})"; - } - if (urlPatternLike != null) { - return "AND path LIKE {urlPatternLike:String}"; - } - if (routePath != null && routePath !== "") { - return "AND path = {routePath:String}"; - } - return ""; -} - -export function getClickmapViewportFilter(min: number | undefined, max: number | undefined): string { - const clauses: string[] = []; - if (min != null) clauses.push("AND viewport_width >= {viewportWidthMin:UInt32}"); - if (max != null) clauses.push("AND viewport_width <= {viewportWidthMax:UInt32}"); - return clauses.join(" "); -} - -export function getClickmapUserFilter(userId: string | undefined): string { - if (userId == null || userId === "") return ""; - return "AND user_id = {userId:Nullable(String)}"; -} - -export function getClickmapReplayFilter(replayId: string | undefined): string { - if (replayId == null || replayId === "") return ""; - return "AND session_replay_id = {replayId:Nullable(String)}"; -} - -export function getClickmapUserAndReplayFilter(userId: string | undefined, replayId: string | undefined): string { - return [getClickmapUserFilter(userId), getClickmapReplayFilter(replayId)].filter((filter) => filter !== "").join(" "); -} -export function getClickmapOriginFilter(): string { - return "AND (url = {origin:String} OR startsWith(url, {originSlashPrefix:String}) OR startsWith(url, {originQueryPrefix:String}) OR startsWith(url, {originHashPrefix:String}))"; -} + const userIds = result.users.map((row) => row.id); + const prisma = await getPrismaClientForTenancy(tenancy); + const dbUsers = userIds.length === 0 ? [] : await prisma.$replica().projectUser.findMany({ + where: { + tenancyId: tenancy.id, + projectUserId: { in: userIds }, + }, + include: userFullInclude, + }); + const userProfilesById = new Map(dbUsers.map((user) => { + const crud = userPrismaToCrud(user, tenancy.config); + return [crud.id, { + display_name: crud.display_name, + primary_email: crud.primary_email, + profile_image_url: crud.profile_image_url, + }]; + })); -export function getClickmapOriginParams(origin: string): { - origin: string, - originSlashPrefix: string, - originQueryPrefix: string, - originHashPrefix: string, -} { return { - origin, - originSlashPrefix: `${origin}/`, - originQueryPrefix: `${origin}?`, - originHashPrefix: `${origin}#`, + kind: "session_replay_clicks", + cells: [], + sampling: result.samplingPct / 100, + routes: result.routes, + users: result.users.map((row) => { + const profile = userProfilesById.get(row.id); + return { + id: row.id, + display_name: profile?.display_name ?? null, + primary_email: profile?.primary_email ?? null, + profile_image_url: profile?.profile_image_url ?? null, + clicks: row.clicks, + replays: row.replays, + last_event_at_millis: row.last_event_at_millis, + }; + }), + replays: result.replays.map((row) => ({ + id: row.id, + user_id: row.linked_user_id, + route_path: row.route_path, + viewport_width: row.viewport_width, + viewport_height: row.viewport_height, + clicks: row.clicks, + last_event_at_millis: row.last_event_at_millis, + })), + selectors: result.selectors, + elements: result.elements, }; } -export function getClickmapSystemElementFilter(): string { - return [ - "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", - ].join(" "); -} - -export function clampClickmapSampling(value: number | undefined): number { - if (value == null || !Number.isFinite(value)) return 1; - if (value <= 0) return 0.01; - if (value > 1) return 1; - return value; -} - -export function buildHourOfWeekHeatmapCells(rows: { weekday: number | string, hour: number | string, value: number | string }[]) { - const byCell = new Map(); - for (const row of rows) { - const weekday = Number(row.weekday); - const hour = Number(row.hour); - if (!Number.isInteger(weekday) || weekday < 1 || weekday > 7) continue; - if (!Number.isInteger(hour) || hour < 0 || hour > 23) continue; - byCell.set(`${weekday}:${hour}`, Number(row.value)); +async function handleTeamHourOfWeek(tenancy: Tenancy, body: HeatmapRequestBody, since: Date, until: Date): Promise { + if (body.member_user_ids.length === 0) { + return emptyHeatmapResponse(body.kind, buildHourOfWeekHeatmapCells([])); } - const cells: { weekday: number, hour: number, value: number }[] = []; - for (let weekday = 1; weekday <= 7; weekday += 1) { - for (let hour = 0; hour < 24; hour += 1) { - cells.push({ weekday, hour, value: byCell.get(`${weekday}:${hour}`) ?? 0 }); - } + const client = getClickhouseAdminClientForMetrics(); + try { + const result = await client.query({ + query: ` + SELECT toDayOfWeek(event_at) AS weekday, toHour(event_at) AS hour, uniqExact(assumeNotNull(user_id)) AS value + FROM analytics_internal.events + WHERE project_id = {projectId:String} + AND branch_id = {branchId:String} + AND user_id IN {memberUserIds:Array(String)} + AND event_at >= {since:DateTime} + AND event_at < {until:DateTime} + GROUP BY weekday, hour + ORDER BY weekday ASC, hour ASC + `, + query_params: { + projectId: tenancy.project.id, + branchId: tenancy.branchId, + memberUserIds: body.member_user_ids, + since: formatClickhouseDateTimeParam(since), + until: formatClickhouseDateTimeParam(until), + }, + format: "JSONEachRow", + }); + const rows: { weekday: number | string, hour: number | string, value: number | string }[] = await result.json(); + return emptyHeatmapResponse(body.kind, buildHourOfWeekHeatmapCells(rows)); + } catch (error) { + throwClickhouseHeatmapError(error, { + captureLabel: "internal-analytics-heatmap-clickhouse-fallback", + context: { projectId: tenancy.project.id, branchId: tenancy.branchId, kind: body.kind }, + }); } - return cells; } export const POST = createSmartRouteHandler({ @@ -156,21 +169,7 @@ export const POST = createSmartRouteHandler({ type: adminAuthTypeSchema.defined(), tenancy: adaptSchema.defined(), }), - body: yupObject({ - kind: yupString().oneOf(["team_user_hour_of_week", "session_replay_clicks"]).defined(), - member_user_ids: yupArray(yupString().defined()).optional().default([]).max(MAX_TEAM_MEMBER_IDS), - route_path: yupString().optional(), - route_regex: yupString().optional(), - url_pattern: yupString().optional(), - user_id: yupString().optional(), - replay_id: yupString().optional(), - device: yupString().oneOf(["tv", "widescreen", "desktop", "laptop", "tablet", "mobile"]).optional(), - viewport_width_min: yupNumber().integer().min(0).max(65535).optional(), - viewport_width_max: yupNumber().integer().min(0).max(65535).optional(), - sampling: yupNumber().min(0).max(1).optional(), - since: yupString().defined(), - until: yupString().defined(), - }).defined(), + body: heatmapRequestBodySchema, }), response: yupObject({ statusCode: yupNumber().oneOf([200]).defined(), @@ -187,255 +186,10 @@ export const POST = createSmartRouteHandler({ throw new StatusError(StatusError.BadRequest, `Heatmap window cannot exceed ${MAX_WINDOW_DAYS} days`); } - const client = getClickhouseAdminClientForMetrics(); + const responseBody = body.kind === "session_replay_clicks" + ? await handleClickHeatmap(auth.tenancy, body, since, until) + : await handleTeamHourOfWeek(auth.tenancy, body, since, until); - try { - if (body.kind === "session_replay_clicks") { - const deviceBucket = getDeviceViewportBucket(body.device); - // Explicit min/max win over the legacy device bucket so callers can - // narrow further (e.g. mobile + viewport_width_min=400). - const viewportMin = body.viewport_width_min ?? deviceBucket?.min; - const viewportMax = body.viewport_width_max ?? deviceBucket?.max; - const urlPatternLike = buildClickmapUrlLikePattern(body.url_pattern); - const samplingPct = Math.max(1, Math.round(clampClickmapSampling(body.sampling) * 100)); - const samplingScale = 100 / samplingPct; - const samplingClause = samplingPct < 100 - ? "AND intHash32(toUInt32(toUnixTimestamp(event_at)) + cityHash64(coalesce(toString(user_id), ''))) % 100 < {samplingPct:UInt32}" - : ""; - const routeFilter = getClickmapRouteFilter(body.route_path, body.route_regex, urlPatternLike); - const userAndReplayFilter = getClickmapUserAndReplayFilter(body.user_id, body.replay_id); - const viewportFilter = getClickmapViewportFilter(viewportMin, viewportMax); - const systemElementFilter = getClickmapSystemElementFilter(); - const params: Record = { - projectId: auth.tenancy.project.id, - branchId: auth.tenancy.branchId, - since: formatClickhouseDateTimeParam(since), - until: formatClickhouseDateTimeParam(until), - linkedLimit: LINKED_LIMIT, - routeLimit: ROUTE_LIMIT, - elementsChainLimit: ELEMENTS_CHAIN_LIMIT, - samplingPct, - ...(body.route_path ? { routePath: body.route_path } : {}), - ...(body.route_regex ? { routeRegex: body.route_regex } : {}), - ...(urlPatternLike != null ? { urlPatternLike } : {}), - ...(body.user_id ? { userId: body.user_id } : {}), - ...(body.replay_id ? { replayId: body.replay_id } : {}), - ...(viewportMin != null ? { viewportWidthMin: viewportMin } : {}), - ...(viewportMax != null ? { viewportWidthMax: viewportMax } : {}), - }; - const sharedWhere = ` - project_id = {projectId:String} - AND branch_id = {branchId:String} - AND event_at >= {since:DateTime} - AND event_at < {until:DateTime} - ${routeFilter} - ${viewportFilter} - ${systemElementFilter} - ${samplingClause} - `; - const [routesResult, usersResult, replaysResult, selectorsResult, elementsResult] = await Promise.all([ - client.query({ - query: ` - SELECT - path, - count() AS clicks, - uniqExactIf(assumeNotNull(user_id), user_id IS NOT NULL) AS users, - uniqExactIf(assumeNotNull(session_replay_id), session_replay_id IS NOT NULL) AS replays - FROM analytics_internal.clickmap_events - WHERE ${sharedWhere} - AND path != '' - ${userAndReplayFilter} - GROUP BY path - ORDER BY clicks DESC - LIMIT {routeLimit:UInt32} - `, - query_params: params, - format: "JSONEachRow", - }), - client.query({ - query: ` - SELECT - assumeNotNull(user_id) AS id, - count() AS clicks, - uniqExactIf(assumeNotNull(session_replay_id), session_replay_id IS NOT NULL) AS replays, - toUnixTimestamp64Milli(max(event_at)) AS last_event_at_millis - FROM analytics_internal.clickmap_events - WHERE ${sharedWhere} - AND user_id IS NOT NULL - ${userAndReplayFilter} - GROUP BY id - ORDER BY last_event_at_millis DESC, clicks DESC - LIMIT {linkedLimit:UInt32} - `, - query_params: params, - format: "JSONEachRow", - }), - client.query({ - query: ` - SELECT - assumeNotNull(session_replay_id) AS id, - any(user_id) AS linked_user_id, - nullIf(any(path), '') AS route_path, - toInt32(any(viewport_width)) AS viewport_width, - toInt32(any(viewport_height)) AS viewport_height, - count() AS clicks, - toUnixTimestamp64Milli(max(event_at)) AS last_event_at_millis - FROM analytics_internal.clickmap_events - WHERE ${sharedWhere} - AND session_replay_id IS NOT NULL - ${userAndReplayFilter} - GROUP BY id - ORDER BY clicks DESC - LIMIT {linkedLimit:UInt32} - `, - query_params: params, - format: "JSONEachRow", - }), - client.query({ - query: ` - SELECT - nullIf(selector, '') AS selector, - count() AS clicks - FROM analytics_internal.clickmap_events - WHERE ${sharedWhere} - AND selector != '' - ${userAndReplayFilter} - GROUP BY selector - ORDER BY clicks DESC - LIMIT {linkedLimit:UInt32} - `, - query_params: params, - format: "JSONEachRow", - }), - client.query({ - query: ` - SELECT - elements_chain, - any(elements_text) AS elements_text, - any(tag_name) AS tag_name, - any(href) AS href, - count() AS clicks - FROM analytics_internal.clickmap_events - WHERE ${sharedWhere} - AND elements_chain != '' - ${userAndReplayFilter} - GROUP BY elements_chain - ORDER BY clicks DESC - LIMIT {elementsChainLimit:UInt32} - `, - query_params: params, - format: "JSONEachRow", - }), - ]); - const routes: { path: string, clicks: number | string, users: number | string, replays: number | string }[] = await routesResult.json(); - const users: { id: string, clicks: number | string, replays: number | string, last_event_at_millis: number | string }[] = await usersResult.json(); - const replays: { 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 }[] = await replaysResult.json(); - const selectors: { selector: string, clicks: number | string }[] = await selectorsResult.json(); - const elements: { elements_chain: string, elements_text: string, tag_name: string, href: string | null, clicks: number | string }[] = await elementsResult.json(); - const userIds = users.map((row) => row.id); - const prisma = await getPrismaClientForTenancy(auth.tenancy); - const dbUsers = userIds.length === 0 ? [] : await prisma.$replica().projectUser.findMany({ - where: { - tenancyId: auth.tenancy.id, - projectUserId: { in: userIds }, - }, - include: userFullInclude, - }); - const userProfilesById = new Map(dbUsers.map((user) => { - const crud = userPrismaToCrud(user, auth.tenancy.config); - return [crud.id, { - display_name: crud.display_name, - primary_email: crud.primary_email, - profile_image_url: crud.profile_image_url, - }]; - })); - - const scaleCount = (value: number | string) => Math.round(Number(value) * samplingScale); - return { - statusCode: 200, - bodyType: "json", - body: { - kind: body.kind, - cells: [], - sampling: samplingPct / 100, - routes: routes.map((row) => ({ path: row.path, clicks: scaleCount(row.clicks), users: scaleCount(row.users), replays: scaleCount(row.replays) })), - users: users.map((row) => { - const profile = userProfilesById.get(row.id); - return { - id: row.id, - display_name: profile?.display_name ?? null, - primary_email: profile?.primary_email ?? null, - profile_image_url: profile?.profile_image_url ?? null, - clicks: scaleCount(row.clicks), - replays: scaleCount(row.replays), - last_event_at_millis: Number(row.last_event_at_millis), - }; - }), - replays: replays.map((row) => ({ - id: row.id, - 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), - })), - selectors: selectors.map((row) => ({ selector: row.selector, clicks: scaleCount(row.clicks) })), - elements: elements.map((row) => ({ - elements_chain: row.elements_chain, - elements_text: row.elements_text, - tag_name: row.tag_name, - href: row.href, - clicks: scaleCount(row.clicks), - })), - }, - }; - } - - if (body.member_user_ids.length === 0) { - return { statusCode: 200, bodyType: "json", body: { kind: body.kind, cells: buildHourOfWeekHeatmapCells([]), sampling: 1, routes: [], users: [], replays: [], selectors: [], elements: [] } }; - } - - const result = await client.query({ - query: ` - SELECT toDayOfWeek(event_at) AS weekday, toHour(event_at) AS hour, uniqExact(assumeNotNull(user_id)) AS value - FROM analytics_internal.events - WHERE project_id = {projectId:String} - AND branch_id = {branchId:String} - AND user_id IN {memberUserIds:Array(String)} - AND event_at >= {since:DateTime} - AND event_at < {until:DateTime} - GROUP BY weekday, hour - ORDER BY weekday ASC, hour ASC - `, - query_params: { - projectId: auth.tenancy.project.id, - branchId: auth.tenancy.branchId, - memberUserIds: body.member_user_ids, - since: formatClickhouseDateTimeParam(since), - until: formatClickhouseDateTimeParam(until), - }, - format: "JSONEachRow", - }); - const rows: { weekday: number | string, hour: number | string, value: number | string }[] = await result.json(); - return { statusCode: 200, bodyType: "json", body: { kind: body.kind, cells: buildHourOfWeekHeatmapCells(rows), sampling: 1, routes: [], users: [], replays: [], selectors: [], elements: [] } }; - } catch (error) { - if (!(error instanceof ClickHouseError)) { - throw error; - } - if ( - body.kind === "session_replay_clicks" && - body.route_regex != null && - body.route_regex !== "" && - isClickhouseRegexpError(error) - ) { - throw new StatusError(StatusError.BadRequest, "Invalid route regex"); - } - captureError("internal-analytics-heatmap-clickhouse-fallback", new HexclaveAssertionError( - "Failed to load analytics heatmap due to ClickHouse query failure.", - { cause: error, projectId: auth.tenancy.project.id, branchId: auth.tenancy.branchId, kind: body.kind }, - )); - throw new StatusError(StatusError.ServiceUnavailable, "Analytics heatmap is temporarily unavailable."); - } + return { statusCode: 200, bodyType: "json", body: responseBody } as const; }, }); diff --git a/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.test.ts b/apps/backend/src/lib/analytics-clickmap-query.test.ts similarity index 91% rename from apps/backend/src/app/api/latest/internal/analytics/heatmap/route.test.ts rename to apps/backend/src/lib/analytics-clickmap-query.test.ts index 6f36f2115b..7c9b9b91f7 100644 --- a/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.test.ts +++ b/apps/backend/src/lib/analytics-clickmap-query.test.ts @@ -5,16 +5,14 @@ import { clampClickmapSampling, getClickmapOriginFilter, getClickmapOriginParams, - getClickmapReplayFilter, getClickmapRouteFilter, getClickmapSystemElementFilter, getClickmapUserAndReplayFilter, - getClickmapUserFilter, getClickmapViewportFilter, getDeviceViewportBucket, -} from "./route"; +} from "./analytics-clickmap-query"; -describe("analytics heatmap helpers", () => { +describe("analytics clickmap query helpers", () => { it("pads sparse hour-of-week rows into a complete 7x24 grid", () => { const cells = buildHourOfWeekHeatmapCells([ { weekday: "1", hour: "0", value: "3" }, @@ -106,13 +104,10 @@ describe("analytics heatmap helpers", () => { }); it("binds clickmap user/replay filters as nullable to match the MV schema", () => { - expect(getClickmapUserFilter("user-123")).toMatchInlineSnapshot(`"AND user_id = {userId:Nullable(String)}"`); - expect(getClickmapUserFilter(undefined)).toMatchInlineSnapshot(`""`); - expect(getClickmapReplayFilter("replay-123")).toMatchInlineSnapshot(`"AND session_replay_id = {replayId:Nullable(String)}"`); - expect(getClickmapReplayFilter(undefined)).toMatchInlineSnapshot(`""`); 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", () => { diff --git a/apps/backend/src/lib/analytics-clickmap-query.ts b/apps/backend/src/lib/analytics-clickmap-query.ts new file mode 100644 index 0000000000..a61b794669 --- /dev/null +++ b/apps/backend/src/lib/analytics-clickmap-query.ts @@ -0,0 +1,404 @@ +import { ClickHouseError, type ClickHouseClient } from "@clickhouse/client"; +import { DEV_TOOL_CLASS_PREFIX, DEV_TOOL_LEGACY_CLASS, DEV_TOOL_ROOT_ID } from "@stackframe/stack-shared/dist/utils/dev-tool"; +import { HexclaveAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; + +// Canonical owner of the ClickHouse clickmap query: filter/param builders, the +// shared aggregate queries, and result scaling. Both the admin route +// (`internal/analytics/heatmap`) and the origin-token public route +// (`analytics/heatmap`) drive their `session_replay_clicks` results through here +// so the SQL and sampling math live in exactly one place. + +const CLICKMAP_TABLE = "analytics_internal.clickmap_events"; + +// --------------------------------------------------------------------------- +// Date / error helpers +// --------------------------------------------------------------------------- + +export function formatClickhouseDateTimeParam(date: Date): string { + return date.toISOString().slice(0, 19); +} + +export function parseBoundedDateTime(value: string, name: string): Date { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) { + throw new StatusError(StatusError.BadRequest, `Invalid ${name}`); + } + return date; +} + +// ClickHouse raises a query-execution error when a user-supplied route regex +// fails to compile. Only those errors should be reported as a 400 "Invalid +// route regex"; unrelated ClickHouse failures must fall through to the generic +// service-unavailable path instead of being misattributed to the regex. +export function isClickhouseRegexpError(error: ClickHouseError): boolean { + return /regexp|regular expression|cannot compile/i.test(error.message); +} + +/** + * Translate a heatmap query failure into the right StatusError. A failed + * user-supplied route regex becomes a 400; any other ClickHouse failure is + * captured and surfaced as a generic 503; non-ClickHouse errors are rethrown + * untouched. Always throws. + */ +export function throwClickhouseHeatmapError(error: unknown, options: { + captureLabel: string, + routeRegex?: string, + context: Record, +}): never { + if (!(error instanceof ClickHouseError)) { + throw error; + } + if (options.routeRegex != null && options.routeRegex !== "" && isClickhouseRegexpError(error)) { + throw new StatusError(StatusError.BadRequest, "Invalid route regex"); + } + captureError(options.captureLabel, new HexclaveAssertionError( + "Failed to load analytics heatmap due to ClickHouse query failure.", + { cause: error, ...options.context }, + )); + throw new StatusError(StatusError.ServiceUnavailable, "Analytics heatmap is temporarily unavailable."); +} + +// --------------------------------------------------------------------------- +// Filter / param builders +// --------------------------------------------------------------------------- + +// Device class buckets — kept as a back-compat shim for callers that still pass +// `device`. Internally collapsed into viewport_width_min/max so the MV order key +// (which leads with viewport_width) does the work instead of a multiIf scan. +const DEVICE_WIDTH_BUCKETS = new Map([ + ["tv", { min: 1920, max: 65535 }], + ["widescreen", { min: 1440, max: 1919 }], + ["desktop", { min: 1200, max: 1439 }], + ["laptop", { min: 1024, max: 1199 }], + ["tablet", { min: 768, max: 1023 }], + ["mobile", { min: 0, max: 767 }], +]); + +export function getDeviceViewportBucket(device: string | undefined): { min: number, max: number } | null { + if (device == null || device === "") return null; + return DEVICE_WIDTH_BUCKETS.get(device) ?? null; +} + +// Translate a PostHog-style URL pattern with `*` wildcards into a SQL LIKE +// pattern, escaping the underlying `_` / `%` / `\` so they're treated literally. +// Empty string disables the filter. +export function buildClickmapUrlLikePattern(urlPattern: string | undefined): string | null { + if (urlPattern == null || urlPattern === "") return null; + const escaped = urlPattern.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); + return escaped.replace(/\*/g, "%"); +} + +export function getClickmapRouteFilter(routePath: string | undefined, routeRegex: string | undefined, urlPatternLike: string | null): string { + if (routeRegex != null && routeRegex !== "") { + return "AND match(path, {routeRegex:String})"; + } + if (urlPatternLike != null) { + return "AND path LIKE {urlPatternLike:String}"; + } + if (routePath != null && routePath !== "") { + return "AND path = {routePath:String}"; + } + return ""; +} + +export function getClickmapViewportFilter(min: number | undefined, max: number | undefined): string { + const clauses: string[] = []; + if (min != null) clauses.push("AND viewport_width >= {viewportWidthMin:UInt32}"); + if (max != null) clauses.push("AND viewport_width <= {viewportWidthMax:UInt32}"); + return clauses.join(" "); +} + +export function getClickmapUserAndReplayFilter(userId: string | undefined, replayId: string | undefined): string { + const clauses: string[] = []; + if (userId != null && userId !== "") clauses.push("AND user_id = {userId:Nullable(String)}"); + if (replayId != null && replayId !== "") clauses.push("AND session_replay_id = {replayId:Nullable(String)}"); + return clauses.join(" "); +} + +export function getClickmapOriginFilter(): string { + return "AND (url = {origin:String} OR startsWith(url, {originSlashPrefix:String}) OR startsWith(url, {originQueryPrefix:String}) OR startsWith(url, {originHashPrefix:String}))"; +} + +export function getClickmapOriginParams(origin: string): { + origin: string, + originSlashPrefix: string, + originQueryPrefix: string, + originHashPrefix: string, +} { + return { + origin, + originSlashPrefix: `${origin}/`, + originQueryPrefix: `${origin}?`, + originHashPrefix: `${origin}#`, + }; +} + +// Exclude clicks landing on the in-page dev tool / heatmap overlay itself. The +// dev-tool identity comes from shared constants so this SQL can never silently +// drift from the actual DOM markers (see `utils/dev-tool`). +export function getClickmapSystemElementFilter(): string { + return [ + `AND position(elements_chain, '${DEV_TOOL_ROOT_ID}') = 0`, + `AND position(elements_chain, '${DEV_TOOL_LEGACY_CLASS}') = 0`, + `AND position(elements_chain, '${DEV_TOOL_CLASS_PREFIX}') = 0`, + `AND position(selector, '#${DEV_TOOL_ROOT_ID}') = 0`, + `AND position(selector, '.${DEV_TOOL_LEGACY_CLASS}') = 0`, + `AND position(selector, '.${DEV_TOOL_CLASS_PREFIX}') = 0`, + ].join(" "); +} + +export function clampClickmapSampling(value: number | undefined): number { + if (value == null || !Number.isFinite(value)) return 1; + if (value <= 0) return 0.01; + if (value > 1) return 1; + return value; +} + +export function buildHourOfWeekHeatmapCells(rows: { weekday: number | string, hour: number | string, value: number | string }[]) { + const byCell = new Map(); + for (const row of rows) { + const weekday = Number(row.weekday); + const hour = Number(row.hour); + if (!Number.isInteger(weekday) || weekday < 1 || weekday > 7) continue; + if (!Number.isInteger(hour) || hour < 0 || hour > 23) continue; + byCell.set(`${weekday}:${hour}`, Number(row.value)); + } + + const cells: { weekday: number, hour: number, value: number }[] = []; + for (let weekday = 1; weekday <= 7; weekday += 1) { + for (let hour = 0; hour < 24; hour += 1) { + cells.push({ weekday, hour, value: byCell.get(`${weekday}:${hour}`) ?? 0 }); + } + } + return cells; +} + +// --------------------------------------------------------------------------- +// Shared clicks query runner +// --------------------------------------------------------------------------- + +export type ClickmapClicksQueryInput = { + projectId: string, + branchId: string, + since: Date, + until: Date, + routePath?: string, + routeRegex?: string, + urlPattern?: string, + userId?: string, + replayId?: string, + device?: string, + viewportWidthMin?: number, + viewportWidthMax?: number, + sampling?: number, + routeLimit: number, + elementsChainLimit: number, + // When set, scope to the exact origin (public origin-token route). + origin?: string, + // When set, also fetch per-user and per-replay aggregates (admin route), + // capped at this limit. Public callers omit it and get neither. + linkedLimit?: number, +}; + +type ClickmapRouteRow = { path: string, clicks: number, users: number, replays: number }; +type ClickmapSelectorRow = { selector: string, clicks: number }; +type ClickmapElementRow = { elements_chain: string, elements_text: string, tag_name: string, href: string | null, clicks: number }; +type ClickmapUserRow = { id: string, clicks: number, replays: number, last_event_at_millis: number }; +type ClickmapReplayRow = { + id: string, + linked_user_id: string | null, + route_path: string | null, + viewport_width: number | null, + viewport_height: number | null, + clicks: number, + last_event_at_millis: number, +}; + +export type ClickmapClicksQueryResult = { + samplingPct: number, + routes: ClickmapRouteRow[], + selectors: ClickmapSelectorRow[], + elements: ClickmapElementRow[], + // Present only when `linkedLimit` was provided. + users: ClickmapUserRow[], + replays: ClickmapReplayRow[], +}; + +/** + * Build the shared WHERE/params, run the routes/selectors/elements aggregates + * (plus per-user/per-replay aggregates when `linkedLimit` is set), and return + * counts already scaled back up by 1/sampling. Throws ClickHouseError on query + * failure — callers translate that into the appropriate StatusError. + */ +export async function runClickmapClicksQuery( + client: ClickHouseClient, + input: ClickmapClicksQueryInput, +): Promise { + const deviceBucket = getDeviceViewportBucket(input.device); + // Explicit min/max win over the legacy device bucket so callers can narrow + // further (e.g. mobile + viewport_width_min=400). + const viewportMin = input.viewportWidthMin ?? deviceBucket?.min; + 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}" + : ""; + const routeFilter = getClickmapRouteFilter(input.routePath, input.routeRegex, urlPatternLike); + const userAndReplayFilter = getClickmapUserAndReplayFilter(input.userId, input.replayId); + const viewportFilter = getClickmapViewportFilter(viewportMin, viewportMax); + const systemElementFilter = getClickmapSystemElementFilter(); + const originFilter = input.origin != null ? getClickmapOriginFilter() : ""; + + const params: Record = { + projectId: input.projectId, + branchId: input.branchId, + since: formatClickhouseDateTimeParam(input.since), + until: formatClickhouseDateTimeParam(input.until), + routeLimit: input.routeLimit, + elementsChainLimit: input.elementsChainLimit, + samplingPct, + ...(input.linkedLimit != null ? { linkedLimit: input.linkedLimit } : {}), + ...(input.origin != null ? getClickmapOriginParams(input.origin) : {}), + ...(input.routePath ? { routePath: input.routePath } : {}), + ...(input.routeRegex ? { routeRegex: input.routeRegex } : {}), + ...(urlPatternLike != null ? { urlPatternLike } : {}), + ...(input.userId ? { userId: input.userId } : {}), + ...(input.replayId ? { replayId: input.replayId } : {}), + ...(viewportMin != null ? { viewportWidthMin: viewportMin } : {}), + ...(viewportMax != null ? { viewportWidthMax: viewportMax } : {}), + }; + + const sharedWhere = ` + project_id = {projectId:String} + AND branch_id = {branchId:String} + AND event_at >= {since:DateTime} + AND event_at < {until:DateTime} + ${originFilter} + ${routeFilter} + ${viewportFilter} + ${systemElementFilter} + ${samplingClause} + `; + + const runJson = async (query: string): Promise => { + const result = await client.query({ query, query_params: params, format: "JSONEachRow" }); + return await result.json(); + }; + + const routesQuery = runJson<{ path: string, clicks: number | string, users: number | string, replays: number | string }>(` + SELECT + path, + count() AS clicks, + uniqExactIf(assumeNotNull(user_id), user_id IS NOT NULL) AS users, + uniqExactIf(assumeNotNull(session_replay_id), session_replay_id IS NOT NULL) AS replays + FROM ${CLICKMAP_TABLE} + WHERE ${sharedWhere} + AND path != '' + ${userAndReplayFilter} + GROUP BY path + ORDER BY clicks DESC + LIMIT {routeLimit:UInt32} + `); + + const selectorsQuery = runJson<{ selector: string, clicks: number | string }>(` + SELECT + nullIf(selector, '') AS selector, + count() AS clicks + FROM ${CLICKMAP_TABLE} + WHERE ${sharedWhere} + AND selector != '' + ${userAndReplayFilter} + GROUP BY selector + ORDER BY clicks DESC + LIMIT {routeLimit:UInt32} + `); + + const elementsQuery = runJson<{ elements_chain: string, elements_text: string, tag_name: string, href: string | null, clicks: number | string }>(` + SELECT + elements_chain, + any(elements_text) AS elements_text, + any(tag_name) AS tag_name, + any(href) AS href, + count() AS clicks + FROM ${CLICKMAP_TABLE} + WHERE ${sharedWhere} + AND elements_chain != '' + ${userAndReplayFilter} + GROUP BY elements_chain + ORDER BY clicks DESC + LIMIT {elementsChainLimit:UInt32} + `); + + const usersQuery = input.linkedLimit == null ? null : runJson<{ id: string, clicks: number | string, replays: number | string, last_event_at_millis: number | string }>(` + SELECT + assumeNotNull(user_id) AS id, + count() AS clicks, + uniqExactIf(assumeNotNull(session_replay_id), session_replay_id IS NOT NULL) AS replays, + toUnixTimestamp64Milli(max(event_at)) AS last_event_at_millis + FROM ${CLICKMAP_TABLE} + WHERE ${sharedWhere} + AND user_id IS NOT NULL + ${userAndReplayFilter} + GROUP BY id + ORDER BY last_event_at_millis DESC, clicks DESC + 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 }>(` + SELECT + assumeNotNull(session_replay_id) AS id, + any(user_id) AS linked_user_id, + nullIf(any(path), '') AS route_path, + toInt32(any(viewport_width)) AS viewport_width, + toInt32(any(viewport_height)) AS viewport_height, + count() AS clicks, + toUnixTimestamp64Milli(max(event_at)) AS last_event_at_millis + FROM ${CLICKMAP_TABLE} + WHERE ${sharedWhere} + AND session_replay_id IS NOT NULL + ${userAndReplayFilter} + GROUP BY id + ORDER BY clicks DESC + LIMIT {linkedLimit:UInt32} + `); + + const [routesRows, selectorsRows, elementsRows, userRows, replayRows] = await Promise.all([ + routesQuery, + selectorsQuery, + elementsQuery, + usersQuery ?? Promise.resolve([]), + replaysQuery ?? Promise.resolve([]), + ]); + + return { + 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), + })), + }; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx index bcb22d853a..8d675479f9 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx @@ -32,6 +32,14 @@ import { type DataGridColumnDef, } from "@stackframe/dashboard-ui-components"; import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { + getProjectHeatmapOriginStorageKey, + getProjectHeatmapTokenStorageKey, + HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY, + HEATMAP_OVERLAY_PROJECT_STORAGE_KEY, + HEATMAP_OVERLAY_TOKEN_STORAGE_KEY, + HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT, +} from "@stackframe/stack-shared/dist/utils/analytics-heatmap-overlay"; import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import { ArrowRight, GlobeHemisphereWest } from "@phosphor-icons/react"; @@ -286,26 +294,14 @@ function normalizeOrigin(baseUrl: string): string | null { } } -const HEATMAP_TOKEN_STORAGE_KEY = "hexclave-heatmap-overlay-token"; -const HEATMAP_ORIGIN_STORAGE_KEY = "hexclave-heatmap-overlay-origin"; -const HEATMAP_PROJECT_STORAGE_KEY = "hexclave-heatmap-overlay-project-id"; - -function getProjectHeatmapTokenStorageKey(projectId: string): string { - return `${HEATMAP_TOKEN_STORAGE_KEY}:${projectId}`; -} - -function getProjectHeatmapOriginStorageKey(projectId: string): string { - return `${HEATMAP_ORIGIN_STORAGE_KEY}:${projectId}`; -} - function createConsoleSnippet(token: string, origin: string, projectId: string): string { return [ - `sessionStorage.setItem(${JSON.stringify(HEATMAP_PROJECT_STORAGE_KEY)}, ${JSON.stringify(projectId)});`, + `sessionStorage.setItem(${JSON.stringify(HEATMAP_OVERLAY_PROJECT_STORAGE_KEY)}, ${JSON.stringify(projectId)});`, `sessionStorage.setItem(${JSON.stringify(getProjectHeatmapOriginStorageKey(projectId))}, ${JSON.stringify(origin)});`, `sessionStorage.setItem(${JSON.stringify(getProjectHeatmapTokenStorageKey(projectId))}, ${JSON.stringify(token)});`, - `sessionStorage.setItem(${JSON.stringify(HEATMAP_ORIGIN_STORAGE_KEY)}, ${JSON.stringify(origin)});`, - `sessionStorage.setItem(${JSON.stringify(HEATMAP_TOKEN_STORAGE_KEY)}, ${JSON.stringify(token)});`, - `window.dispatchEvent(new Event("hexclave:heatmap-token-updated"));`, + `sessionStorage.setItem(${JSON.stringify(HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY)}, ${JSON.stringify(origin)});`, + `sessionStorage.setItem(${JSON.stringify(HEATMAP_OVERLAY_TOKEN_STORAGE_KEY)}, ${JSON.stringify(token)});`, + `window.dispatchEvent(new Event(${JSON.stringify(HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT)}));`, `console.info("Hexclave heatmap toolbar enabled for this tab.");`, ].join("\n"); } @@ -315,12 +311,12 @@ function installHeatmapTokenForCurrentOrigin(token: AnalyticsHeatmapTokenRespons return false; } try { - window.sessionStorage.setItem(HEATMAP_PROJECT_STORAGE_KEY, projectId); + window.sessionStorage.setItem(HEATMAP_OVERLAY_PROJECT_STORAGE_KEY, projectId); window.sessionStorage.setItem(getProjectHeatmapOriginStorageKey(projectId), token.origin); window.sessionStorage.setItem(getProjectHeatmapTokenStorageKey(projectId), token.token); - window.sessionStorage.setItem(HEATMAP_ORIGIN_STORAGE_KEY, token.origin); - window.sessionStorage.setItem(HEATMAP_TOKEN_STORAGE_KEY, token.token); - window.dispatchEvent(new Event("hexclave:heatmap-token-updated")); + window.sessionStorage.setItem(HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY, token.origin); + window.sessionStorage.setItem(HEATMAP_OVERLAY_TOKEN_STORAGE_KEY, token.token); + window.dispatchEvent(new Event(HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT)); return true; } catch { window.alert("Could not enable the heatmap toolbar in this tab. Copy the snippet and paste it in the console instead."); diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index c19c24408b..943263da70 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -6,7 +6,7 @@ import type { MoneyAmount } from "../utils/currency-constants"; import type { Json } from "../utils/json"; import { Result } from "../utils/results"; import { urlString } from "../utils/urls"; -import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "./admin-metrics"; +import type { AnalyticsHeatmapDevice, AnalyticsHeatmapKind, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "./admin-metrics"; import type { AnalyticsQueryOptions, AnalyticsQueryResponse } from "./crud/analytics"; import { EmailOutboxCrud } from "./crud/email-outbox"; import { InternalEmailsCrud } from "./crud/emails"; @@ -403,7 +403,7 @@ export class HexclaveAdminInterface extends HexclaveServerInterface { } async getAnalyticsHeatmap(options: { - kind: "team_user_hour_of_week" | "session_replay_clicks", + kind: AnalyticsHeatmapKind, member_user_ids?: string[], route_path?: string, route_regex?: string, diff --git a/packages/stack-shared/src/interface/admin-metrics.ts b/packages/stack-shared/src/interface/admin-metrics.ts index c25ef096e9..8f35307f29 100644 --- a/packages/stack-shared/src/interface/admin-metrics.ts +++ b/packages/stack-shared/src/interface/admin-metrics.ts @@ -309,3 +309,22 @@ export type AnalyticsHeatmapDevice = yup.InferType; export type AnalyticsHeatmapResponse = yup.InferType; export type AnalyticsHeatmapTokenResponse = yup.InferType; + +// Single (camelCase) options shape for the heatmap SDK surface — shared by the +// StackAdminApp interface and its implementation so the two can't drift. The +// HexclaveAdminInterface transport layer maps these to the snake_case request body. +export type AnalyticsHeatmapOptions = { + kind: AnalyticsHeatmapKind, + memberUserIds?: string[], + routePath?: string, + routeRegex?: string, + urlPattern?: string, + userId?: string, + replayId?: string, + device?: AnalyticsHeatmapDevice, + viewportWidthMin?: number, + viewportWidthMax?: number, + sampling?: number, + since: string, + until: string, +}; diff --git a/packages/stack-shared/src/utils/analytics-heatmap-overlay.tsx b/packages/stack-shared/src/utils/analytics-heatmap-overlay.tsx new file mode 100644 index 0000000000..799601c37d --- /dev/null +++ b/packages/stack-shared/src/utils/analytics-heatmap-overlay.tsx @@ -0,0 +1,26 @@ +/** + * Wire protocol for handing a heatmap overlay token from the dashboard to the + * in-page dev tool via `sessionStorage` + a window event. + * + * The dashboard (writer) and the dev tool (reader) live in different packages + * but must agree on these exact key names and event name — this module is the + * single source of truth so they can never silently desync. The reader's + * legacy-fallback logic and the writer's snippet stay in their respective + * packages; only the shared names + key builders live here. + */ + +export const HEATMAP_OVERLAY_TOKEN_STORAGE_KEY = "hexclave-heatmap-overlay-token"; +export const HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY = "hexclave-heatmap-overlay-origin"; +export const HEATMAP_OVERLAY_PROJECT_STORAGE_KEY = "hexclave-heatmap-overlay-project-id"; +export const HEATMAP_OVERLAY_RESUME_STORAGE_KEY = "hexclave-heatmap-overlay-resume"; +export const HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT = "hexclave:heatmap-token-updated"; + +/** Per-project sessionStorage key holding the overlay token. */ +export function getProjectHeatmapTokenStorageKey(projectId: string): string { + return `${HEATMAP_OVERLAY_TOKEN_STORAGE_KEY}:${projectId}`; +} + +/** Per-project sessionStorage key holding the origin the token was minted for. */ +export function getProjectHeatmapOriginStorageKey(projectId: string): string { + return `${HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY}:${projectId}`; +} diff --git a/packages/stack-shared/src/utils/dev-tool.tsx b/packages/stack-shared/src/utils/dev-tool.tsx new file mode 100644 index 0000000000..efa7dd1447 --- /dev/null +++ b/packages/stack-shared/src/utils/dev-tool.tsx @@ -0,0 +1,22 @@ +/** + * Shared identity of the Hexclave in-page dev tool / heatmap overlay. + * + * These constants are the single source of truth for "is this DOM / event / + * stored click part of the dev tool itself?". They are consumed across package + * boundaries: + * - the dev tool mounts its root element with {@link DEV_TOOL_ROOT_ID} and + * prefixes every generated class with {@link DEV_TOOL_CLASS_PREFIX}; + * - the event tracker uses them to skip self-clicks at ingest; + * - the backend clickmap query uses them to filter dev-tool clicks out of + * aggregate heatmaps server-side. + * + * Keep them here so a rename can never silently desync the SQL filter from the + * actual DOM identity. + */ +export const DEV_TOOL_ROOT_ID = "__hexclave-dev-tool-root"; + +/** Prefix applied to every class/generated id the dev tool renders. */ +export const DEV_TOOL_CLASS_PREFIX = "sdt-"; + +/** Legacy class marker still present on older dev-tool builds. */ +export const DEV_TOOL_LEGACY_CLASS = "stack-devtool"; diff --git a/packages/stack-shared/src/utils/dom.tsx b/packages/stack-shared/src/utils/dom.tsx index bf36944e0c..776b8d468b 100644 --- a/packages/stack-shared/src/utils/dom.tsx +++ b/packages/stack-shared/src/utils/dom.tsx @@ -5,3 +5,15 @@ export function hasClickableParent(element: HTMLElement): boolean { return hasClickableParent(element.parentElement); } + +/** + * Escape a string so it is safe to use as a CSS identifier (id/class) inside a selector. + * Prefers the native `CSS.escape` when available, falling back to a conservative + * backslash-escape for non-DOM environments (SSR, tests, older runtimes). + */ +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}`); +} diff --git a/packages/stack-shared/src/utils/elements-chain.tsx b/packages/stack-shared/src/utils/elements-chain.tsx new file mode 100644 index 0000000000..6acd7231df --- /dev/null +++ b/packages/stack-shared/src/utils/elements-chain.tsx @@ -0,0 +1,349 @@ +/** + * PostHog-style `elements_chain` format — the single owner of both halves of the + * contract. The event tracker {@link buildElementsChain serializes} a clicked + * element (and its ancestors) into a string; the heatmap overlay + * {@link parseElementsChain parses} that string back into structured segments so + * it can re-locate the element in a live DOM. + * + * Encode and decode MUST round-trip exactly, which is why they live together: + * the escaping applied here on the write side is reversed by the parser below, + * and a single round-trip test in `elements-chain.test.tsx` guards the pair. + * + * Segment shape (leaf-first, joined by `;`): + * tag.class1.class2:nth-child="2":nth-of-type="1":text="Save":attr__id="x":href="..." + */ + +export type ElementsChainSegment = { + tag: string, + classes: string[], + attrs: Record, + text: string | null, + nthChild: number | null, + nthOfType: number | null, + href: string | null, +}; + +export const ELEMENTS_CHAIN_MAX_DEPTH = 8; +export const ELEMENTS_CHAIN_TEXT_MAX = 80; +export const ELEMENTS_CHAIN_ATTR_MAX = 200; + +// Attributes we serialise into elements_chain. Mirrors the set PostHog persists: +// stable identifiers (id, data-testid), semantics (role, type, name, aria-label), +// and a few we expect downstream tooling to want to match against. +export const ELEMENTS_CHAIN_ATTRS = [ + "id", + "data-testid", + "data-test-id", + "data-hexclave-id", + "name", + "type", + "role", + "aria-label", + "placeholder", + "title", +] as const; + +// --------------------------------------------------------------------------- +// Serialization (DOM element -> elements_chain string) +// --------------------------------------------------------------------------- + +function escapeElementsChainValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +// Class tokens are written into the unquoted, dot-joined prefix of a segment, so +// any "." or ":" inside a class (e.g. Tailwind variants like `md:hover:bg-blue-500` +// or arbitrary values like `w-[1.5rem]`) must be escaped to round-trip through the +// parser, which splits the prefix on unescaped "." and the segment on unescaped ":". +function escapeElementsChainClass(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/\./g, "\\.").replace(/:/g, "\\:"); +} + +function getElementClasses(element: Element): string[] { + const className = (element as HTMLElement).className; + if (typeof className !== "string" || className.trim() === "") { + return []; + } + return className.trim().split(/\s+/).filter(Boolean).slice(0, 4); +} + +function getNthChildIndex(element: Element): number | null { + const parent = element.parentElement; + if (parent == null) return null; + const index = Array.prototype.indexOf.call(parent.children, element); + return index >= 0 ? index + 1 : null; +} + +function getNthOfTypeIndex(element: Element): number | null { + const parent = element.parentElement; + if (parent == null) return null; + const tagName = element.tagName; + const siblings = Array.from(parent.children).filter((child) => child.tagName === tagName); + if (siblings.length <= 1) return null; + const index = siblings.indexOf(element); + return index >= 0 ? index + 1 : null; +} + +function serializeElementsChainSegment(element: Element): string { + const parts: string[] = []; + parts.push(element.tagName.toLowerCase()); + const classes = getElementClasses(element); + if (classes.length > 0) { + parts.push(`.${classes.map(escapeElementsChainClass).join(".")}`); + } + const text = element.textContent.trim().replace(/\s+/g, " ").slice(0, ELEMENTS_CHAIN_TEXT_MAX); + const nthChild = getNthChildIndex(element); + const nthOfType = getNthOfTypeIndex(element); + const attrPairs: string[] = []; + if (nthChild != null) attrPairs.push(`nth-child="${nthChild}"`); + if (nthOfType != null) attrPairs.push(`nth-of-type="${nthOfType}"`); + if (text !== "") attrPairs.push(`text="${escapeElementsChainValue(text)}"`); + for (const attrName of ELEMENTS_CHAIN_ATTRS) { + const value = element.getAttribute(attrName); + if (value == null || value === "") continue; + attrPairs.push(`attr__${attrName}="${escapeElementsChainValue(value.slice(0, ELEMENTS_CHAIN_ATTR_MAX))}"`); + } + if (element.tagName === "A") { + const href = element.getAttribute("href"); + if (href != null && href !== "") { + attrPairs.push(`href="${escapeElementsChainValue(href.slice(0, ELEMENTS_CHAIN_ATTR_MAX))}"`); + } + } + if (attrPairs.length > 0) { + parts.push(`:${attrPairs.join(":")}`); + } + return parts.join(""); +} + +/** + * Serialise a clicked element and up to {@link ELEMENTS_CHAIN_MAX_DEPTH} + * ancestors (leaf-first) into an `elements_chain` string. + */ +export function buildElementsChain(element: Element): string { + const segments: string[] = []; + let current: Element | null = element; + let depth = 0; + while (current != null && depth < ELEMENTS_CHAIN_MAX_DEPTH && current !== document.documentElement) { + segments.push(serializeElementsChainSegment(current)); + current = current.parentElement; + depth += 1; + } + return segments.join(";"); +} + +// --------------------------------------------------------------------------- +// Parsing (elements_chain string -> structured segments) +// --------------------------------------------------------------------------- + +// Split a string on unescaped occurrences of `.`, unescaping `\.`, `\:` and `\\` +// back to their literal characters. Reverses `escapeElementsChainClass`. +function splitEscapedDots(input: string): string[] { + const out: string[] = []; + let cur = ''; + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + if (ch === '\\' && i + 1 < input.length) { + cur += input[i + 1]; + i += 1; + continue; + } + if (ch === '.') { + out.push(cur); + cur = ''; + continue; + } + cur += ch; + } + out.push(cur); + return out; +} + +type ElementsChainAttrResult = { + nthChild?: number, + nthOfType?: number, + text?: string, + href?: string, + attrKey?: string, + attrValue?: string, +}; + +function applyElementsChainAttr(key: string, value: string): ElementsChainAttrResult { + if (key === 'nth-child') { + const n = Number.parseInt(value, 10); + return Number.isFinite(n) ? { nthChild: n } : {}; + } + if (key === 'nth-of-type') { + const n = Number.parseInt(value, 10); + return Number.isFinite(n) ? { nthOfType: n } : {}; + } + if (key === 'text') { + return { text: value }; + } + if (key === 'href') { + return { href: value, attrKey: key, attrValue: value }; + } + if (key.startsWith('attr__')) { + return { attrKey: key.slice('attr__'.length), attrValue: value }; + } + return { attrKey: key, attrValue: value }; +} + +function parseElementsChainSegment(segment: string): ElementsChainSegment | null { + const trimmed = segment.trim(); + if (trimmed === '') return null; + + // Find first ':' at top level — separates tag/classes prefix from attribute pairs. + let prefixEnd = trimmed.length; + let inQuotes = false; + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i]; + if (ch === '\\' && i + 1 < trimmed.length) { + i += 1; + continue; + } + if (ch === '"') { + inQuotes = !inQuotes; + continue; + } + if (ch === ':' && !inQuotes) { + prefixEnd = i; + break; + } + } + + const prefix = trimmed.slice(0, prefixEnd); + const rest = trimmed.slice(prefixEnd); + const prefixParts = splitEscapedDots(prefix); + const tag = prefixParts[0].trim().toLowerCase(); + if (tag === '') return null; + const classes = prefixParts.slice(1).map((c) => c.trim()).filter((c) => c !== ''); + + const attrs: Record = {}; + let nthChild: number | null = null; + let nthOfType: number | null = null; + let text: string | null = null; + let href: string | null = null; + + // Parse :key="value" pairs from rest. + let i = 0; + while (i < rest.length) { + if (rest[i] !== ':') { + i += 1; + continue; + } + i += 1; // skip ':' + // read key up to '=' + let keyEnd = i; + while (keyEnd < rest.length && rest[keyEnd] !== '=' && rest[keyEnd] !== ':') keyEnd += 1; + const key = rest.slice(i, keyEnd).trim(); + if (keyEnd >= rest.length || rest[keyEnd] !== '=') { + i = keyEnd; + continue; + } + let valStart = keyEnd + 1; + if (rest[valStart] !== '"') { + // unquoted — read until next ':' at top level + let end = valStart; + while (end < rest.length && rest[end] !== ':') end += 1; + const value = rest.slice(valStart, end); + const result = applyElementsChainAttr(key, value); + if (result.nthChild != null) nthChild = result.nthChild; + if (result.nthOfType != null) nthOfType = result.nthOfType; + if (result.text != null) text = result.text; + if (result.href != null) href = result.href; + if (result.attrKey != null) attrs[result.attrKey] = result.attrValue ?? ''; + i = end; + continue; + } + // quoted value — find unescaped closing quote + valStart += 1; + let end = valStart; + let value = ''; + while (end < rest.length) { + const ch = rest[end]; + if (ch === '\\' && end + 1 < rest.length) { + const next = rest[end + 1]; + if (next === '"' || next === '\\') { + value += next; + end += 2; + continue; + } + value += ch; + end += 1; + continue; + } + if (ch === '"') break; + value += ch; + end += 1; + } + const result = applyElementsChainAttr(key, value); + if (result.nthChild != null) nthChild = result.nthChild; + if (result.nthOfType != null) nthOfType = result.nthOfType; + if (result.text != null) text = result.text; + if (result.href != null) href = result.href; + if (result.attrKey != null) attrs[result.attrKey] = result.attrValue ?? ''; + i = end + 1; // skip closing quote + } + + return { tag, classes, attrs, text, nthChild, nthOfType, href }; +} + +/** Parse an `elements_chain` string into structured, leaf-first segments. */ +export function parseElementsChain(chain: string): ElementsChainSegment[] { + // Split top-level by ';' respecting quoted strings. + const segments: string[] = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < chain.length; i++) { + const ch = chain[i]; + if (ch === '\\' && i + 1 < chain.length) { + current += ch + chain[i + 1]; + i += 1; + continue; + } + if (ch === '"') { + inQuotes = !inQuotes; + current += ch; + continue; + } + if (ch === ';' && !inQuotes) { + segments.push(current); + current = ''; + continue; + } + current += ch; + } + if (current.length > 0) { + segments.push(current); + } + return segments.map(parseElementsChainSegment).filter((segment): segment is ElementsChainSegment => segment != null); +} + +import.meta.vitest?.test("parseElementsChain parses a simple leaf+ancestor chain", ({ expect }) => { + const parsed = parseElementsChain('button.btn.btn-primary:nth-of-type="1":text="Save":attr__id="save-btn";div.container'); + expect(parsed).toEqual([ + { tag: "button", classes: ["btn", "btn-primary"], attrs: { id: "save-btn" }, text: "Save", nthChild: null, nthOfType: 1, href: null }, + { tag: "div", classes: ["container"], attrs: {}, text: null, nthChild: null, nthOfType: null, href: null }, + ]); +}); + +import.meta.vitest?.test("parseElementsChain reverses class escaping for Tailwind-style tokens", ({ expect }) => { + // `md:hover:bg-blue-500` and `w-[1.5rem]` contain the prefix delimiters `:`/`.`, + // so the serializer escapes them; the parser must recover the literal classes. + const parsed = parseElementsChain('a.md\\:hover\\:bg-blue-500.w-\\[1\\.5rem\\]:href="/p?a=1"'); + expect(parsed).toEqual([ + { tag: "a", classes: ["md:hover:bg-blue-500", "w-[1.5rem]"], attrs: { href: "/p?a=1" }, text: null, nthChild: null, nthOfType: null, href: "/p?a=1" }, + ]); +}); + +import.meta.vitest?.test("parseElementsChain unescapes quotes/backslashes and ignores ';' inside quoted values", ({ expect }) => { + const parsed = parseElementsChain('span:text="a \\"b\\"; c \\\\ d"'); + expect(parsed).toEqual([ + { tag: "span", classes: [], attrs: {}, text: 'a "b"; c \\ d', nthChild: null, nthOfType: null, href: null }, + ]); +}); + +import.meta.vitest?.test("parseElementsChain drops empty/tagless segments", ({ expect }) => { + expect(parseElementsChain("")).toEqual([]); + expect(parseElementsChain(";;")).toEqual([]); +}); diff --git a/packages/template/src/dev-tool/dev-tool-core.ts b/packages/template/src/dev-tool/dev-tool-core.ts index 901ff6a834..7e5ce5f350 100644 --- a/packages/template/src/dev-tool/dev-tool-core.ts +++ b/packages/template/src/dev-tool/dev-tool-core.ts @@ -1,6 +1,19 @@ // IF_PLATFORM js-like import type { RequestLogEntry } from "@stackframe/stack-shared/dist/interface/client-interface"; +import { + getProjectHeatmapOriginStorageKey, + getProjectHeatmapTokenStorageKey, + HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY, + HEATMAP_OVERLAY_PROJECT_STORAGE_KEY, + HEATMAP_OVERLAY_RESUME_STORAGE_KEY, + HEATMAP_OVERLAY_TOKEN_STORAGE_KEY, + HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT, +} from "@stackframe/stack-shared/dist/utils/analytics-heatmap-overlay"; +import { DEV_TOOL_ROOT_ID } from "@stackframe/stack-shared/dist/utils/dev-tool"; +import { cssEscapeIdent } from "@stackframe/stack-shared/dist/utils/dom"; +import { AnalyticsHeatmapResponseBodySchema, type AnalyticsHeatmapResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { parseElementsChain, type ElementsChainSegment } from "@stackframe/stack-shared/dist/utils/elements-chain"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; @@ -53,7 +66,7 @@ type DevToolState = { // Hexclave rebrand: UI-only local prefs — straight rename (one-time reset is harmless) const STORAGE_KEY = '__hexclave-dev-tool-state'; const TRIGGER_POS_KEY = 'hexclave-devtool-trigger-position'; -const ROOT_ID = '__hexclave-dev-tool-root'; +const ROOT_ID = DEV_TOOL_ROOT_ID; const GLOBAL_INSTANCE_KEY = '__hexclave-dev-tool-instance'; const MAX_LOG_ENTRIES = 500; const CONSOLE_LOG_BATCH_SIZE = 100; @@ -1798,10 +1811,6 @@ type HeatmapGroupOverlayElement = { outline: HTMLElement; }; -const HEATMAP_TOOL_ROOT_ID = '__hexclave-dev-tool-root'; -const HEATMAP_TOKEN_STORAGE_KEY = 'hexclave-heatmap-overlay-token'; -const HEATMAP_ORIGIN_STORAGE_KEY = 'hexclave-heatmap-overlay-origin'; -const HEATMAP_PROJECT_STORAGE_KEY = 'hexclave-heatmap-overlay-project-id'; const HEATMAP_FILTERS_STORAGE_KEY = 'hexclave-heatmap-overlay-filters'; type HeatmapRangeKey = '24h' | '7d' | '30d'; @@ -1861,200 +1870,6 @@ type DevToolServerHeatmap = { elements: DevToolServerHeatmapElement[]; }; -type ElementsChainSegment = { - tag: string; - classes: string[]; - attrs: Record; - text: string | null; - nthChild: number | null; - nthOfType: number | null; - href: string | null; -}; - -function parseElementsChain(chain: string): ElementsChainSegment[] { - // Split top-level by ';' respecting quoted strings. - const segments: string[] = []; - let current = ''; - let inQuotes = false; - for (let i = 0; i < chain.length; i++) { - const ch = chain[i]; - if (ch === '\\' && i + 1 < chain.length) { - current += ch + chain[i + 1]; - i += 1; - continue; - } - if (ch === '"') { - inQuotes = !inQuotes; - current += ch; - continue; - } - if (ch === ';' && !inQuotes) { - segments.push(current); - current = ''; - continue; - } - current += ch; - } - if (current.length > 0) { - segments.push(current); - } - return segments.map(parseElementsChainSegment).filter((segment): segment is ElementsChainSegment => segment != null); -} - -// Split a string on unescaped occurrences of `.`, unescaping `\.`, `\:` and `\\` -// back to their literal characters. Used to recover class tokens from the -// dot-joined segment prefix, which the event tracker escapes during serialization. -function splitEscapedDots(input: string): string[] { - const out: string[] = []; - let cur = ''; - for (let i = 0; i < input.length; i++) { - const ch = input[i]; - if (ch === '\\' && i + 1 < input.length) { - cur += input[i + 1]; - i += 1; - continue; - } - if (ch === '.') { - out.push(cur); - cur = ''; - continue; - } - cur += ch; - } - out.push(cur); - return out; -} - -function parseElementsChainSegment(segment: string): ElementsChainSegment | null { - const trimmed = segment.trim(); - if (trimmed === '') return null; - - // Find first ':' at top level — separates tag/classes prefix from attribute pairs. - let prefixEnd = trimmed.length; - let inQuotes = false; - for (let i = 0; i < trimmed.length; i++) { - const ch = trimmed[i]; - if (ch === '\\' && i + 1 < trimmed.length) { - i += 1; - continue; - } - if (ch === '"') { - inQuotes = !inQuotes; - continue; - } - if (ch === ':' && !inQuotes) { - prefixEnd = i; - break; - } - } - - const prefix = trimmed.slice(0, prefixEnd); - const rest = trimmed.slice(prefixEnd); - const prefixParts = splitEscapedDots(prefix); - const tag = prefixParts[0].trim().toLowerCase(); - if (tag === '') return null; - const classes = prefixParts.slice(1).map((c) => c.trim()).filter((c) => c !== ''); - - const attrs: Record = {}; - let nthChild: number | null = null; - let nthOfType: number | null = null; - let text: string | null = null; - let href: string | null = null; - - // Parse :key="value" pairs from rest. - let i = 0; - while (i < rest.length) { - if (rest[i] !== ':') { - i += 1; - continue; - } - i += 1; // skip ':' - // read key up to '=' - let keyEnd = i; - while (keyEnd < rest.length && rest[keyEnd] !== '=' && rest[keyEnd] !== ':') keyEnd += 1; - const key = rest.slice(i, keyEnd).trim(); - if (keyEnd >= rest.length || rest[keyEnd] !== '=') { - i = keyEnd; - continue; - } - let valStart = keyEnd + 1; - if (rest[valStart] !== '"') { - // unquoted — read until next ':' at top level - let end = valStart; - while (end < rest.length && rest[end] !== ':') end += 1; - const value = rest.slice(valStart, end); - const result = applyElementsChainAttr(key, value); - if (result.nthChild != null) nthChild = result.nthChild; - if (result.nthOfType != null) nthOfType = result.nthOfType; - if (result.text != null) text = result.text; - if (result.href != null) href = result.href; - if (result.attrKey != null) attrs[result.attrKey] = result.attrValue ?? ''; - i = end; - continue; - } - // quoted value — find unescaped closing quote - valStart += 1; - let end = valStart; - let value = ''; - while (end < rest.length) { - const ch = rest[end]; - if (ch === '\\' && end + 1 < rest.length) { - const next = rest[end + 1]; - if (next === '"' || next === '\\') { - value += next; - end += 2; - continue; - } - value += ch; - end += 1; - continue; - } - if (ch === '"') break; - value += ch; - end += 1; - } - const result = applyElementsChainAttr(key, value); - if (result.nthChild != null) nthChild = result.nthChild; - if (result.nthOfType != null) nthOfType = result.nthOfType; - if (result.text != null) text = result.text; - if (result.href != null) href = result.href; - if (result.attrKey != null) attrs[result.attrKey] = result.attrValue ?? ''; - i = end + 1; // skip closing quote - } - - return { tag, classes, attrs, text, nthChild, nthOfType, href }; -} - -type ElementsChainAttrResult = { - nthChild?: number, - nthOfType?: number, - text?: string, - href?: string, - attrKey?: string, - attrValue?: string, -}; - -function applyElementsChainAttr(key: string, value: string): ElementsChainAttrResult { - if (key === 'nth-child') { - const n = Number.parseInt(value, 10); - return Number.isFinite(n) ? { nthChild: n } : {}; - } - if (key === 'nth-of-type') { - const n = Number.parseInt(value, 10); - return Number.isFinite(n) ? { nthOfType: n } : {}; - } - if (key === 'text') { - return { text: value }; - } - if (key === 'href') { - return { href: value, attrKey: key, attrValue: value }; - } - if (key.startsWith('attr__')) { - return { attrKey: key.slice('attr__'.length), attrValue: value }; - } - return { attrKey: key, attrValue: value }; -} - function cssEscapeAttrValue(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } @@ -2065,13 +1880,6 @@ function readChainAttr(segment: ElementsChainSegment, attr: string): string { return typeof value === 'string' ? value : ''; } -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, (ch) => `\\${ch}`); -} - function formatHeatmapCount(value: number): string { if (value >= 1000) return `${Math.round(value / 100) / 10}k`; return String(value); @@ -2092,14 +1900,14 @@ function getHeatmapHue(count: number, maxCount: number): number { function isInsideDevToolEvent(event: Event): boolean { const path = typeof event.composedPath === 'function' ? event.composedPath() : []; for (const node of path) { - if (node instanceof Element && node.id === HEATMAP_TOOL_ROOT_ID) { + if (node instanceof Element && node.id === ROOT_ID) { return true; } } // Fallback for environments without composedPath: best-effort ancestor walk. const target = event.target; if (target instanceof Element) { - return target.closest(`#${HEATMAP_TOOL_ROOT_ID}`) != null; + return target.closest(`#${ROOT_ID}`) != null; } return false; } @@ -2145,7 +1953,7 @@ function maybeNavigateFromHeatmapModifierClick(event: Event): boolean { // private history-state marker, so a generic pushState+popstate doesn't // re-render the tree), so we hard-nav and rehydrate the panel on load. try { - sessionStorage.setItem('hexclave-heatmap-overlay-resume', '1'); + sessionStorage.setItem(HEATMAP_OVERLAY_RESUME_STORAGE_KEY, '1'); } catch { // ignore (private mode, etc.) } @@ -2170,7 +1978,7 @@ function getReadableElementLabel(element: Element): string { } function isElementVisibleForHeatmap(element: Element): boolean { - if (element.closest(`#${HEATMAP_TOOL_ROOT_ID}`) != null) { + if (element.closest(`#${ROOT_ID}`) != null) { return false; } if (element.closest('[hidden], [aria-hidden="true"], [inert]') != null) { @@ -2196,14 +2004,6 @@ function getElementFromSelector(selector: string): Element | null { } } -function getProjectHeatmapTokenStorageKey(projectId: string): string { - return `${HEATMAP_TOKEN_STORAGE_KEY}:${projectId}`; -} - -function getProjectHeatmapOriginStorageKey(projectId: string): string { - return `${HEATMAP_ORIGIN_STORAGE_KEY}:${projectId}`; -} - function getSessionStorageString(key: string): string | null { try { const value = sessionStorage.getItem(key); @@ -2214,7 +2014,7 @@ function getSessionStorageString(key: string): string | null { } function getActiveHeatmapProjectId(fallbackProjectId: string): string { - return getSessionStorageString(HEATMAP_PROJECT_STORAGE_KEY) ?? fallbackProjectId; + return getSessionStorageString(HEATMAP_OVERLAY_PROJECT_STORAGE_KEY) ?? fallbackProjectId; } function removeSessionStorageItem(key: string): void { @@ -2252,7 +2052,7 @@ function getHeatmapTokenFromStorage(projectId: string): string | null { if (projectToken != null) { return projectToken; } - const legacyToken = getSessionStorageString(HEATMAP_TOKEN_STORAGE_KEY); + const legacyToken = getSessionStorageString(HEATMAP_OVERLAY_TOKEN_STORAGE_KEY); if (legacyToken == null) { return null; } @@ -2262,78 +2062,46 @@ function getHeatmapTokenFromStorage(projectId: string): string | null { function getHeatmapOriginFromStorage(projectId: string): string | null { const activeProjectId = getActiveHeatmapProjectId(projectId); - return getSessionStorageString(getProjectHeatmapOriginStorageKey(activeProjectId)) ?? getSessionStorageString(HEATMAP_ORIGIN_STORAGE_KEY); + return getSessionStorageString(getProjectHeatmapOriginStorageKey(activeProjectId)) ?? getSessionStorageString(HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY); } function clearHeatmapTokenStorage(projectId: string): void { const activeProjectId = getActiveHeatmapProjectId(projectId); removeSessionStorageItem(getProjectHeatmapTokenStorageKey(activeProjectId)); removeSessionStorageItem(getProjectHeatmapOriginStorageKey(activeProjectId)); - removeSessionStorageItem(HEATMAP_PROJECT_STORAGE_KEY); - const legacyToken = getSessionStorageString(HEATMAP_TOKEN_STORAGE_KEY); + removeSessionStorageItem(HEATMAP_OVERLAY_PROJECT_STORAGE_KEY); + const legacyToken = getSessionStorageString(HEATMAP_OVERLAY_TOKEN_STORAGE_KEY); const legacyProjectId = legacyToken == null ? null : getJwtPayloadProjectId(legacyToken); if (legacyProjectId == null || legacyProjectId === activeProjectId) { - removeSessionStorageItem(HEATMAP_TOKEN_STORAGE_KEY); - removeSessionStorageItem(HEATMAP_ORIGIN_STORAGE_KEY); - } -} - -function readNumberProperty(value: unknown, key: string): number | null { - if (typeof value !== 'object' || value === null) { - return null; - } - const property = Reflect.get(value, key); - return typeof property === 'number' && Number.isFinite(property) ? property : null; -} - -function readStringProperty(value: unknown, key: string): string | null { - if (typeof value !== 'object' || value === null) { - return null; + removeSessionStorageItem(HEATMAP_OVERLAY_TOKEN_STORAGE_KEY); + removeSessionStorageItem(HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY); } - const property = Reflect.get(value, key); - return typeof property === 'string' && property !== '' ? property : null; } function parseServerHeatmapResponse(value: unknown, path: string): DevToolServerHeatmap { - const selectorsValue = typeof value === 'object' && value !== null ? Reflect.get(value, 'selectors') : undefined; - const selectors: DevToolServerHeatmapSelector[] = []; - if (Array.isArray(selectorsValue)) { - for (const selectorValue of selectorsValue) { - const selector = readStringProperty(selectorValue, 'selector'); - const clicks = readNumberProperty(selectorValue, 'clicks'); - if (selector != null && clicks != null) { - selectors.push({ selector, clicks }); - } - } - } - - const elementsValue = typeof value === 'object' && value !== null ? Reflect.get(value, 'elements') : undefined; - const elements: DevToolServerHeatmapElement[] = []; - if (Array.isArray(elementsValue)) { - for (const elementValue of elementsValue) { - const elementsChain = readStringProperty(elementValue, 'elements_chain'); - const clicks = readNumberProperty(elementValue, 'clicks'); - if (elementsChain == null || clicks == null) continue; - elements.push({ - elementsChain, - elementsText: readStringProperty(elementValue, 'elements_text') ?? '', - tagName: readStringProperty(elementValue, 'tag_name') ?? '', - href: readStringProperty(elementValue, 'href'), - clicks, - }); - } - } - - const routesValue = typeof value === 'object' && value !== null ? Reflect.get(value, 'routes') : undefined; - let totalClicks = 0; - if (Array.isArray(routesValue)) { - for (const routeValue of routesValue) { - const clicks = readNumberProperty(routeValue, 'clicks'); - if (clicks != null) totalClicks += clicks; - } + let parsed: AnalyticsHeatmapResponse; + try { + // Validate against the canonical response contract instead of hand-walking + // `unknown`. Anything that doesn't match is treated as "no data" so the + // overlay stays alive rather than crashing on shape drift. + parsed = AnalyticsHeatmapResponseBodySchema.validateSync(value); + } catch { + return { path, totalClicks: 0, selectors: [], elements: [] }; } - - return { path, totalClicks, selectors, elements }; + return { + path, + // True aggregate across every matching route, independent of what the + // current DOM can render. + totalClicks: parsed.routes.reduce((sum, route) => sum + route.clicks, 0), + selectors: parsed.selectors.map((selector) => ({ selector: selector.selector, clicks: selector.clicks })), + elements: parsed.elements.map((element) => ({ + elementsChain: element.elements_chain, + elementsText: element.elements_text, + tagName: element.tag_name, + href: element.href, + clicks: element.clicks, + })), + }; } // Heuristic: does this path segment look like an opaque per-entity id (a UUID, @@ -3101,7 +2869,20 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe runAsynchronously(loadServerHeatmap()); }; const routePollInterval = window.setInterval(scheduleRender, 500); - const mutationObserver = new MutationObserver(() => { + // Mutations the overlay/dev-tool cause themselves must not drive a re-render: + // `renderOverlay` rewrites marker/outline inline styles into `overlayRoot` on + // every paint, so observing them would re-arm scheduleRender → paint → mutate + // → … a permanent render loop while the tab is open. Ignore records whose + // targets all sit inside our own overlay root or dev-tool root. + const isSelfMutationTarget = (target: Node | null): boolean => { + const element = target instanceof Element ? target : target?.parentElement ?? null; + if (element == null) return false; + return overlayRoot.contains(element) || element.closest(`#${cssEscapeIdent(ROOT_ID)}`) != null; + }; + const mutationObserver = new MutationObserver((mutations) => { + if (mutations.every((mutation) => isSelfMutationTarget(mutation.target))) { + return; + } scheduleDomIndexInvalidation(); scheduleRender(); }); @@ -3124,7 +2905,7 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe window.addEventListener('resize', onWindowResize); visualViewport?.addEventListener('resize', scheduleRender); visualViewport?.addEventListener('scroll', scheduleRender); - window.addEventListener('hexclave:heatmap-token-updated', onTokenUpdated); + window.addEventListener(HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT, onTokenUpdated); render(); runAsynchronously(loadServerHeatmap()); @@ -3150,7 +2931,7 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe window.removeEventListener('resize', onWindowResize); visualViewport?.removeEventListener('resize', scheduleRender); visualViewport?.removeEventListener('scroll', scheduleRender); - window.removeEventListener('hexclave:heatmap-token-updated', onTokenUpdated); + window.removeEventListener(HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT, onTokenUpdated); overlayRoot.remove(); }, }; @@ -3870,9 +3651,9 @@ export function createDevTool(app: StackClientApp): () => void { // panel so the user picks up where they left off. let shouldResumeHeatmap = false; try { - if (sessionStorage.getItem('hexclave-heatmap-overlay-resume') === '1') { + if (sessionStorage.getItem(HEATMAP_OVERLAY_RESUME_STORAGE_KEY) === '1') { shouldResumeHeatmap = true; - sessionStorage.removeItem('hexclave-heatmap-overlay-resume'); + sessionStorage.removeItem(HEATMAP_OVERLAY_RESUME_STORAGE_KEY); } } catch { // ignore @@ -3885,7 +3666,7 @@ export function createDevTool(app: StackClientApp): () => void { openPanel(); } - window.addEventListener('hexclave:heatmap-token-updated', openHeatmapPanel); + window.addEventListener(HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT, openHeatmapPanel); const removeRequestListener = app[stackAppInternalsSymbol].addRequestListener((entry: RequestLogEntry) => { const timestamp = Date.now(); @@ -3914,7 +3695,7 @@ export function createDevTool(app: StackClientApp): () => void { setGlobalDevToolInstance(null); } trigger.cleanup(); - window.removeEventListener('hexclave:heatmap-token-updated', openHeatmapPanel); + window.removeEventListener(HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT, openHeatmapPanel); removeRequestListener(); panel?.cleanup(); if (root.parentNode) { diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index c7a8667003..fc5207cc53 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -1,7 +1,7 @@ import { KnownErrors, HexclaveAdminInterface } from "@stackframe/stack-shared"; import { getProductionModeErrors } from "@stackframe/stack-shared/dist/helpers/production-mode"; import { InternalApiKeyCreateCrudResponse } from "@stackframe/stack-shared/dist/interface/admin-interface"; -import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import type { AnalyticsHeatmapOptions, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@stackframe/stack-shared/dist/interface/crud/analytics"; import { EmailTemplateCrud } from "@stackframe/stack-shared/dist/interface/crud/email-templates"; import { InternalApiKeysCrud } from "@stackframe/stack-shared/dist/interface/crud/internal-api-keys"; @@ -1168,21 +1168,7 @@ export class _StackAdminAppImplIncomplete { + async getAnalyticsHeatmap(options: AnalyticsHeatmapOptions): Promise { return await this._interface.getAnalyticsHeatmap({ kind: options.kind, member_user_ids: options.memberUserIds, diff --git a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts index 19767f09db..664822d76c 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/event-tracker.ts @@ -1,3 +1,6 @@ +import { DEV_TOOL_ROOT_ID } from "@stackframe/stack-shared/dist/utils/dev-tool"; +import { cssEscapeIdent } from "@stackframe/stack-shared/dist/utils/dom"; +import { buildElementsChain, ELEMENTS_CHAIN_MAX_DEPTH } from "@stackframe/stack-shared/dist/utils/elements-chain"; import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; @@ -27,120 +30,9 @@ function hasHistoryMethods(value: unknown): value is { pushState: History["pushS return typeof value.pushState === "function" && typeof value.replaceState === "function"; } -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}`); -} - // Pixel quantization factor for x/y/viewport in stored click events. Matches the // SCALE_FACTOR used by the ClickHouse clickmap_events MV — keep them in sync. const CLICKMAP_SCALE_FACTOR = 16; -const ELEMENTS_CHAIN_MAX_DEPTH = 8; -const ELEMENTS_CHAIN_TEXT_MAX = 80; -const ELEMENTS_CHAIN_ATTR_MAX = 200; -const HEXCLAVE_DEV_TOOL_ROOT_ID = "__hexclave-dev-tool-root"; -// Attributes we serialise into elements_chain. Mirrors the set PostHog persists: -// stable identifiers (id, data-testid), semantics (role, type, name, aria-label), -// and a few we expect downstream tooling to want to match against. -const ELEMENTS_CHAIN_ATTRS = [ - "id", - "data-testid", - "data-test-id", - "data-hexclave-id", - "name", - "type", - "role", - "aria-label", - "placeholder", - "title", -] as const; - -function escapeElementsChainValue(value: string): string { - return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); -} - -// Class tokens are written into the unquoted, dot-joined prefix of a segment, so -// any "." or ":" inside a class (e.g. Tailwind variants like `md:hover:bg-blue-500` -// or arbitrary values like `w-[1.5rem]`) must be escaped to round-trip through the -// overlay parser, which splits the prefix on unescaped "." and the segment on -// unescaped ":". -function escapeElementsChainClass(value: string): string { - return value.replace(/\\/g, "\\\\").replace(/\./g, "\\.").replace(/:/g, "\\:"); -} - -function getElementClasses(element: Element): string[] { - const className = (element as HTMLElement).className; - if (typeof className !== "string" || className.trim() === "") { - return []; - } - return className.trim().split(/\s+/).filter(Boolean).slice(0, 4); -} - -function getNthChildIndex(element: Element): number | null { - const parent = element.parentElement; - if (parent == null) return null; - const index = Array.prototype.indexOf.call(parent.children, element); - return index >= 0 ? index + 1 : null; -} - -function getNthOfTypeIndex(element: Element): number | null { - const parent = element.parentElement; - if (parent == null) return null; - const tagName = element.tagName; - const siblings = Array.from(parent.children).filter((child) => child.tagName === tagName); - if (siblings.length <= 1) return null; - const index = siblings.indexOf(element); - return index >= 0 ? index + 1 : null; -} - -// Serialise one DOM element into a PostHog-compatible elements_chain segment. -// tag.class1.class2:nth-child="2":nth-of-type="1":text="Save":attr__id="save-btn":href="..." -// Segments are joined with ";" from leaf to root so downstream matchers can -// LIKE against any ancestor cheaply. -function serializeElementsChainSegment(element: Element): string { - const parts: string[] = []; - parts.push(element.tagName.toLowerCase()); - const classes = getElementClasses(element); - if (classes.length > 0) { - parts.push(`.${classes.map(escapeElementsChainClass).join(".")}`); - } - const text = element.textContent.trim().replace(/\s+/g, " ").slice(0, ELEMENTS_CHAIN_TEXT_MAX); - const nthChild = getNthChildIndex(element); - const nthOfType = getNthOfTypeIndex(element); - const attrPairs: string[] = []; - if (nthChild != null) attrPairs.push(`nth-child="${nthChild}"`); - if (nthOfType != null) attrPairs.push(`nth-of-type="${nthOfType}"`); - if (text !== "") attrPairs.push(`text="${escapeElementsChainValue(text)}"`); - for (const attrName of ELEMENTS_CHAIN_ATTRS) { - const value = element.getAttribute(attrName); - if (value == null || value === "") continue; - attrPairs.push(`attr__${attrName}="${escapeElementsChainValue(value.slice(0, ELEMENTS_CHAIN_ATTR_MAX))}"`); - } - if (element.tagName === "A") { - const href = element.getAttribute("href"); - if (href != null && href !== "") { - attrPairs.push(`href="${escapeElementsChainValue(href.slice(0, ELEMENTS_CHAIN_ATTR_MAX))}"`); - } - } - if (attrPairs.length > 0) { - parts.push(`:${attrPairs.join(":")}`); - } - return parts.join(""); -} - -function buildElementsChain(element: Element): string { - const segments: string[] = []; - let current: Element | null = element; - let depth = 0; - while (current != null && depth < ELEMENTS_CHAIN_MAX_DEPTH && current !== document.documentElement) { - segments.push(serializeElementsChainSegment(current)); - current = current.parentElement; - depth += 1; - } - return segments.join(";"); -} function isPointerTargetFixed(element: Element): boolean { let current: Element | null = element; @@ -157,7 +49,7 @@ function isPointerTargetFixed(element: Element): boolean { } function isInsideHexclaveDevTool(element: Element): boolean { - return element.closest(`#${cssEscapeIdent(HEXCLAVE_DEV_TOOL_ROOT_ID)}`) != null; + return element.closest(`#${cssEscapeIdent(DEV_TOOL_ROOT_ID)}`) != null; } export type EventTrackerDeps = { diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index 783880612b..5b7683af8a 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -1,4 +1,4 @@ -import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import type { AnalyticsHeatmapOptions, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@stackframe/stack-shared/dist/interface/crud/analytics"; import type { AdminGetSessionReplayChunkEventsResponse, AdminGetSessionReplayAllEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-replays"; import type { Transaction, TransactionType } from "@stackframe/stack-shared/dist/interface/crud/transactions"; @@ -154,21 +154,7 @@ export type StackAdminApp, queryAnalytics(options: AnalyticsQueryOptions): Promise, - getAnalyticsHeatmap(options: { - kind: "team_user_hour_of_week" | "session_replay_clicks", - memberUserIds?: string[], - routePath?: string, - routeRegex?: string, - urlPattern?: string, - userId?: string, - replayId?: string, - device?: AnalyticsHeatmapDevice, - viewportWidthMin?: number, - viewportWidthMax?: number, - sampling?: number, - since: string, - until: string, - }): Promise, + getAnalyticsHeatmap(options: AnalyticsHeatmapOptions): Promise, createAnalyticsHeatmapToken(options: { origin: string }): Promise, listSessionReplays(options?: ListSessionReplaysOptions): Promise, From 85a950522a2910a655cc687b612ed647f887f987 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Fri, 29 May 2026 11:32:50 -0700 Subject: [PATCH 07/10] refactor(analytics): rename heatmap references to clickmap for consistency - Updated all instances of "heatmap" to "clickmap" in the analytics module to reflect the new terminology. - Adjusted console messages, alerts, and UI elements to align with the clickmap branding. - Modified navigation items and dev-tool components to ensure cohesive user experience across the application. --- .../analytics/heatmaps/page-client.tsx | 20 +-- apps/dashboard/src/lib/apps-frontend.tsx | 2 +- .../template/src/dev-tool/dev-tool-core.ts | 153 ++++-------------- .../template/src/dev-tool/dev-tool-styles.ts | 9 -- 4 files changed, 41 insertions(+), 143 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx index 8d675479f9..4661041ceb 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx @@ -302,7 +302,7 @@ function createConsoleSnippet(token: string, origin: string, projectId: string): `sessionStorage.setItem(${JSON.stringify(HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY)}, ${JSON.stringify(origin)});`, `sessionStorage.setItem(${JSON.stringify(HEATMAP_OVERLAY_TOKEN_STORAGE_KEY)}, ${JSON.stringify(token)});`, `window.dispatchEvent(new Event(${JSON.stringify(HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT)}));`, - `console.info("Hexclave heatmap toolbar enabled for this tab.");`, + `console.info("Hexclave clickmap toolbar enabled for this tab.");`, ].join("\n"); } @@ -319,7 +319,7 @@ function installHeatmapTokenForCurrentOrigin(token: AnalyticsHeatmapTokenRespons window.dispatchEvent(new Event(HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT)); return true; } catch { - window.alert("Could not enable the heatmap toolbar in this tab. Copy the snippet and paste it in the console instead."); + window.alert("Could not enable the clickmap toolbar in this tab. Copy the snippet and paste it in the console instead."); return false; } } @@ -337,19 +337,19 @@ function HeatmapTokenDialog(props: { - Enable heatmap toolbar + Enable clickmap toolbar Paste this in the console on {props.origin?.origin ?? "the selected site"}. The token expires in 24 hours. {props.token == null ? ( - Creating heatmap token... + Creating clickmap token... ) : ( <> - The site will use normal client authentication plus this origin-bound heatmap token to fetch aggregate clickmap data. + The site will use normal client authentication plus this origin-bound clickmap token to fetch aggregate clickmap data. )} @@ -417,7 +417,7 @@ export default function PageClient() { const installedInCurrentTab = installHeatmapTokenForCurrentOrigin(created, adminApp.projectId); try { await navigator.clipboard.writeText(createConsoleSnippet(created.token, created.origin, adminApp.projectId)); - toast({ title: installedInCurrentTab ? "Heatmap toolbar enabled" : "Snippet copied to clipboard" }); + toast({ title: installedInCurrentTab ? "Clickmap toolbar enabled" : "Snippet copied to clipboard" }); } catch { // Clipboard access can be denied (e.g. lost user-gesture after the // network round-trip); the snippet stays available to copy manually. @@ -427,7 +427,7 @@ export default function PageClient() { return ( @@ -442,7 +442,7 @@ export default function PageClient() { setCustomOrigin(event.target.value)} placeholder="http://localhost:3000" /> @@ -451,7 +451,7 @@ export default function PageClient() { {origins.length === 0 ? ( - Add a trusted domain before launching a production heatmap. + Add a trusted domain before launching a production clickmap. ) : ( @@ -469,7 +469,7 @@ export default function PageClient() { diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index 901da69eb9..469592f3e3 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -394,7 +394,7 @@ export const ALL_APPS_FRONTEND = { navigationItems: [ { displayName: "Tables", href: "./tables" }, { displayName: "Replays", href: "../session-replays" }, - { displayName: "Heatmaps", href: "./heatmaps" }, + { displayName: "Clickmaps", href: "./heatmaps" }, { displayName: "Queries", href: "./queries" }, ], screenshots: [], diff --git a/packages/template/src/dev-tool/dev-tool-core.ts b/packages/template/src/dev-tool/dev-tool-core.ts index 7e5ce5f350..24f7e9f56a 100644 --- a/packages/template/src/dev-tool/dev-tool-core.ts +++ b/packages/template/src/dev-tool/dev-tool-core.ts @@ -75,7 +75,7 @@ const DOCS_URL = 'https://docs.hexclave.com'; const TABS: { id: TabId; label: string; icon: string }[] = [ { id: 'overview', label: 'Overview', icon: '' }, - { id: 'heatmaps', label: 'Heatmaps', icon: '' }, + { id: 'heatmaps', label: 'Clickmaps', icon: '' }, { id: 'customize', label: 'Customize', icon: '' }, { id: 'ai', label: 'AI', icon: '' }, { id: 'console', label: 'Console', icon: '' }, @@ -1842,8 +1842,6 @@ function isHeatmapRangeKey(value: unknown): value is HeatmapRangeKey { function isHeatmapDeviceKey(value: unknown): value is HeatmapDeviceKey { return value === 'all' || value === 'mobile' || value === 'tablet' || value === 'laptop' || value === 'desktop' || value === 'widescreen' || value === 'tv'; } -const HEATMAP_BLOCKED_POINTER_EVENTS = ['pointerdown', 'mousedown', 'mouseup', 'click', 'dblclick', 'auxclick', 'contextmenu'] as const; -const HEATMAP_BLOCKED_KEY_EVENTS = ['keydown', 'keypress', 'keyup', 'beforeinput', 'input'] as const; const HEATMAP_DOM_INDEX_DEBOUNCE_MS = 250; type DevToolServerHeatmapSelector = { @@ -1891,75 +1889,6 @@ function getHeatmapHue(count: number, maxCount: number): number { return 185 - Math.round(intensity * 155); } -// Use event.composedPath() rather than target.closest(): the path is captured -// at dispatch time and stays valid even if the original target has since been -// detached from the DOM (e.g. an icon's / that was replaced by an -// innerHTML reset between mousedown and click). With .closest(), detached -// targets have no ancestors so the check spuriously returns false, the page- -// interaction blocker eats the click, and the icon button looks dead. -function isInsideDevToolEvent(event: Event): boolean { - const path = typeof event.composedPath === 'function' ? event.composedPath() : []; - for (const node of path) { - if (node instanceof Element && node.id === ROOT_ID) { - return true; - } - } - // Fallback for environments without composedPath: best-effort ancestor walk. - const target = event.target; - if (target instanceof Element) { - return target.closest(`#${ROOT_ID}`) != null; - } - return false; -} - -function isHeatmapNavigationModifierEvent(event: Event): event is MouseEvent { - return event instanceof MouseEvent && (event.metaKey || event.ctrlKey); -} - -function getHeatmapNavigationHref(target: EventTarget | null): string | null { - if (!(target instanceof Element)) { - return null; - } - const link = target.closest('a[href]'); - if (!(link instanceof HTMLAnchorElement)) { - return null; - } - if (link.hasAttribute('download')) { - return null; - } - const href = link.href; - if (href === '' || href.startsWith('javascript:')) { - return null; - } - return href; -} - -function maybeNavigateFromHeatmapModifierClick(event: Event): boolean { - if (!isHeatmapNavigationModifierEvent(event)) { - return false; - } - if (event.type !== 'click') { - return true; - } - - const href = getHeatmapNavigationHref(event.target); - if (href == null) { - return true; - } - - // Drop a sentinel into sessionStorage so the dev tool on the next page can - // auto-reopen straight back into the heatmap tab. We can't soft-navigate - // from outside the host framework reliably (Next.js App Router gates on a - // private history-state marker, so a generic pushState+popstate doesn't - // re-render the tree), so we hard-nav and rehydrate the panel on load. - try { - sessionStorage.setItem(HEATMAP_OVERLAY_RESUME_STORAGE_KEY, '1'); - } catch { - // ignore (private mode, etc.) - } - window.location.assign(href); - return true; -} function getReadableElementLabel(element: Element): string { const ariaLabel = element.getAttribute('aria-label'); @@ -2157,10 +2086,10 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe const selectorCount = h('div', { className: 'sdt-hm-stat-value' }, '0'); const viewportValue = h('div', { className: 'sdt-hm-stat-value' }, `${window.innerWidth}x${window.innerHeight}`); const list = h('div', { className: 'sdt-hm-list' }); - const empty = h('div', { className: 'sdt-hm-empty' }, 'Paste a heatmap token from the dashboard to load aggregated element clicks for this page.'); + const empty = h('div', { className: 'sdt-hm-empty' }, 'Paste a clickmap token from the dashboard to load aggregated element clicks for this page.'); const status = h('div', { className: 'sdt-hm-token-status' }); const overlayToggle = h('button', { className: 'sdt-hm-btn sdt-hm-btn-primary' }, 'Hide'); - const expandButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Expand heatmap options', title: 'Expand heatmap options' }); + const expandButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Expand clickmap options', title: 'Expand clickmap options' }); const backButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Back', title: 'Back' }); const miniClicks = h('span', { className: 'sdt-hm-toolbar-metric' }, '0 clicks'); @@ -2373,8 +2302,8 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe const toolbar = h('div', { className: 'sdt-hm-toolbar' }, backButton, h('div', { className: 'sdt-hm-toolbar-main' }, - h('div', { className: 'sdt-hm-toolbar-title' }, 'Heatmap'), - h('div', { className: 'sdt-hm-toolbar-subtitle' }, 'Page locked while inspecting'), + h('div', { className: 'sdt-hm-toolbar-title' }, 'Clickmap'), + h('div', { className: 'sdt-hm-toolbar-subtitle' }, 'Aggregated clicks for this page'), ), miniClicks, expandButton, @@ -2385,10 +2314,6 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Viewport'), viewportValue), ); - const note = h('div', { className: 'sdt-hm-note' }, - 'Heatmap mode blocks page clicks and keyboard input outside this toolbar while keeping every toolbar control interactive.' - ); - let filters: HeatmapFilters = readStoredFilters(); let filterReloadDebounce = 0; // When the user hasn't typed a custom pattern, the URL pattern field mirrors @@ -2536,12 +2461,12 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe }); // Two regions: a fixed head (filters + a single action row pairing the stat - // chips with the overlay toggle) and a scrolling body (status, note, list). + // chips with the overlay toggle) and a scrolling body (status, list). // Keeping borders to a single head/body divider avoids the dense stack of // bordered bands that made the expanded panel feel congested. const actions = h('div', { className: 'sdt-hm-actions' }, stats, overlayToggle); const head = h('div', { className: 'sdt-hm-head' }, filterRow, actions); - const body = h('div', { className: 'sdt-hm-body' }, status, note, list); + const body = h('div', { className: 'sdt-hm-body' }, status, list); const details = h('div', { className: 'sdt-hm-details' }, head, body); function getGroups(): DevToolClickGroup[] { @@ -2750,8 +2675,8 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe miniClicks.textContent = `${formatHeatmapCount(aggregateClicks)} clicks`; container.classList.toggle('sdt-hm-expanded', expanded); expandButton.setAttribute('aria-expanded', String(expanded)); - expandButton.setAttribute('aria-label', expanded ? 'Collapse heatmap options' : 'Expand heatmap options'); - expandButton.title = expanded ? 'Collapse heatmap options' : 'Expand heatmap options'; + expandButton.setAttribute('aria-label', expanded ? 'Collapse clickmap options' : 'Expand clickmap options'); + expandButton.title = expanded ? 'Collapse clickmap options' : 'Expand clickmap options'; setHtml(expandButton, expanded ? chevronDownSvg : chevronUpSvg); renderOverlay(groups); renderList(groups); @@ -2820,9 +2745,9 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe serverHeatmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; if (error instanceof Error && error.message.includes('Heatmap token does not belong to this project')) { clearHeatmapTokenStorage(app.projectId); - serverHeatmapError = 'The stored heatmap token belongs to another project. Generate a fresh token for this project.'; + serverHeatmapError = 'The stored clickmap token belongs to another project. Generate a fresh token for this project.'; } else { - serverHeatmapError = error instanceof Error ? error.message : 'Failed to load heatmap data'; + serverHeatmapError = error instanceof Error ? error.message : 'Failed to load clickmap data'; } } finally { if (isLatestRequest()) { @@ -2832,29 +2757,21 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe } } - function blockHeatmapPageInteraction(event: Event) { - if (isInsideDevToolEvent(event)) { - return; - } + // The clickmap overlay leaves the page fully interactive. When the user + // navigates away with a token loaded, drop a sentinel so the dev tool on the + // next page can auto-reopen straight back into the clickmap tab. + const onBeforeUnloadResume = () => { const token = getHeatmapTokenFromStorage(app.projectId); const tokenOrigin = getHeatmapOriginFromStorage(app.projectId); - if (token == null || (tokenOrigin != null && tokenOrigin !== window.location.origin) || serverHeatmapError != null) { + if (token == null || (tokenOrigin != null && tokenOrigin !== window.location.origin)) { return; } - - if (maybeNavigateFromHeatmapModifierClick(event)) { - if (event.type === 'click') { - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - } - return; + try { + sessionStorage.setItem(HEATMAP_OVERLAY_RESUME_STORAGE_KEY, '1'); + } catch { + // ignore (private mode, etc.) } - - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - } + }; overlayToggle.addEventListener('click', () => { overlayVisible = !overlayVisible; @@ -2892,12 +2809,7 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe document.body.appendChild(overlayRoot); rebuildDomIndex(); scheduleRender(); - for (const eventName of HEATMAP_BLOCKED_POINTER_EVENTS) { - window.addEventListener(eventName, blockHeatmapPageInteraction, true); - } - for (const eventName of HEATMAP_BLOCKED_KEY_EVENTS) { - window.addEventListener(eventName, blockHeatmapPageInteraction, true); - } + window.addEventListener('beforeunload', onBeforeUnloadResume); const onWindowResize = () => { scheduleRender(); }; @@ -2921,12 +2833,7 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe mutationObserver.disconnect(); clearHeatmapOverlayElements(); domIndex.clear(); - for (const eventName of HEATMAP_BLOCKED_POINTER_EVENTS) { - window.removeEventListener(eventName, blockHeatmapPageInteraction, true); - } - for (const eventName of HEATMAP_BLOCKED_KEY_EVENTS) { - window.removeEventListener(eventName, blockHeatmapPageInteraction, true); - } + window.removeEventListener('beforeunload', onBeforeUnloadResume); document.removeEventListener('scroll', scheduleRender, true); window.removeEventListener('resize', onWindowResize); visualViewport?.removeEventListener('resize', scheduleRender); @@ -3425,10 +3332,10 @@ function createPanel( } let heatmapsCleanup: (() => void) | null = null; - // The heatmaps tab installs global click/key blockers, an overlay root, a - // MutationObserver, and background polling. Unlike other panes it must not be - // cached-and-hidden: tear it down (running its cleanup) whenever we leave it, - // so the page isn't left interaction-blocked with work still running. + // The clickmap tab installs an overlay root, a MutationObserver, and + // background polling. Unlike other panes it must not be cached-and-hidden: + // tear it down (running its cleanup) whenever we leave it, so the overlay and + // its work don't linger after the tab is closed. function teardownHeatmapsPane() { const pane = mountedPanes.get('heatmaps'); if (pane == null) return; @@ -3645,9 +3552,9 @@ export function createDevTool(app: StackClientApp): () => void { const trigger = createTrigger(togglePanel); wrapper.appendChild(trigger.element); - // Resume the heatmap panel after a Cmd+click hard navigation. The handler - // that performed the redirect drops a sentinel into sessionStorage; if it's - // present here, restore the heatmap tab as the active one and reopen the + // Resume the clickmap panel after navigating to a new page. While the overlay + // is mounted it drops a sentinel into sessionStorage on unload; if it's + // present here, restore the clickmap tab as the active one and reopen the // panel so the user picks up where they left off. let shouldResumeHeatmap = false; try { diff --git a/packages/template/src/dev-tool/dev-tool-styles.ts b/packages/template/src/dev-tool/dev-tool-styles.ts index bf0c5f1fbe..5d1c393c44 100644 --- a/packages/template/src/dev-tool/dev-tool-styles.ts +++ b/packages/template/src/dev-tool/dev-tool-styles.ts @@ -2877,15 +2877,6 @@ export const devToolCSS = ` padding: 12px 16px 14px; } - .stack-devtool .sdt-hm-note { - border-radius: var(--sdt-radius); - background: var(--sdt-info-muted); - color: var(--sdt-text-secondary); - padding: 9px 11px; - font-size: 11px; - line-height: 1.5; - } - .stack-devtool .sdt-hm-token-status { color: var(--sdt-text-secondary); padding: 0 2px; From 508f2ff503c61fcb85d59dda1a0d95c285834dad Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Fri, 29 May 2026 13:32:44 -0700 Subject: [PATCH 08/10] feat(analytics): introduce clickmap API routes and related functionality - Added new API routes for clickmap data retrieval, including public and internal endpoints. - Implemented token generation and validation for clickmap access, ensuring secure data handling. - Refactored existing heatmap references to clickmap throughout the codebase for consistency. - Enhanced error handling and validation for clickmap queries, improving robustness. - Updated related components and tests to support the new clickmap features and ensure functionality. --- apps/backend/scripts/clickhouse-migrations.ts | 2 +- .../analytics/{heatmap => clickmap}/route.ts | 36 +- .../route.ts | 8 +- .../analytics/{heatmap => clickmap}/route.ts | 36 +- .../latest/internal/user-activity/route.tsx | 2 +- .../src/lib/analytics-clickmap-query.test.ts | 6 +- .../src/lib/analytics-clickmap-query.ts | 16 +- ...t.ts => analytics-clickmap-tokens.test.ts} | 26 +- .../src/lib/analytics-clickmap-tokens.ts | 104 +++ .../src/lib/analytics-heatmap-tokens.ts | 104 --- .../route-handlers/smart-route-handler.tsx | 4 +- .../projects/[projectId]/(overview)/globe.tsx | 2 +- .../{heatmaps => clickmaps}/page-client.tsx | 80 +- .../{heatmaps => clickmaps}/page.tsx | 0 .../teams/[teamId]/team-analytics.tsx | 36 +- .../users/[userId]/page-client.tsx | 2 +- apps/dashboard/src/lib/apps-frontend.tsx | 2 +- .../api/v1/internal-user-activity.test.ts | 2 +- .../src/interface/admin-interface.ts | 22 +- .../src/interface/admin-metrics.ts | 34 +- .../src/utils/analytics-clickmap-overlay.tsx | 15 + .../src/utils/analytics-heatmap-overlay.tsx | 26 - packages/stack-shared/src/utils/dev-tool.tsx | 4 +- .../stack-shared/src/utils/elements-chain.tsx | 2 +- .../template/src/dev-tool/dev-tool-core.ts | 702 ++++++++++++------ .../template/src/dev-tool/dev-tool-styles.ts | 315 +++++++- .../apps/implementations/admin-app-impl.ts | 10 +- .../implementations/event-tracker.test.ts | 2 +- .../stack-app/apps/interfaces/admin-app.ts | 6 +- 29 files changed, 1046 insertions(+), 560 deletions(-) rename apps/backend/src/app/api/latest/analytics/{heatmap => clickmap}/route.ts (75%) rename apps/backend/src/app/api/latest/internal/analytics/{heatmap-token => clickmap-token}/route.ts (71%) rename apps/backend/src/app/api/latest/internal/analytics/{heatmap => clickmap}/route.ts (81%) rename apps/backend/src/lib/{analytics-heatmap-tokens.test.ts => analytics-clickmap-tokens.test.ts} (54%) create mode 100644 apps/backend/src/lib/analytics-clickmap-tokens.ts delete mode 100644 apps/backend/src/lib/analytics-heatmap-tokens.ts rename apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/{heatmaps => clickmaps}/page-client.tsx (82%) rename apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/{heatmaps => clickmaps}/page.tsx (100%) create mode 100644 packages/stack-shared/src/utils/analytics-clickmap-overlay.tsx delete mode 100644 packages/stack-shared/src/utils/analytics-heatmap-overlay.tsx diff --git a/apps/backend/scripts/clickhouse-migrations.ts b/apps/backend/scripts/clickhouse-migrations.ts index 9031d1e05f..830b78d7c3 100644 --- a/apps/backend/scripts/clickhouse-migrations.ts +++ b/apps/backend/scripts/clickhouse-migrations.ts @@ -702,7 +702,7 @@ ORDER BY (project_id, branch_id, toDate(event_at), path, viewport_width); // Materialized view that auto-populates clickmap_events on every $click insert. // No POPULATE clause: existing rows are not backfilled (they remain queryable -// via the existing /api/.../analytics/heatmap route which still reads from +// via the existing /api/.../analytics/clickmap route which still reads from // analytics_internal.events). New click rows flow into both tables. // // All field accesses use the toFloat64OrZero(toString(...)) pattern that the diff --git a/apps/backend/src/app/api/latest/analytics/heatmap/route.ts b/apps/backend/src/app/api/latest/analytics/clickmap/route.ts similarity index 75% rename from apps/backend/src/app/api/latest/analytics/heatmap/route.ts rename to apps/backend/src/app/api/latest/analytics/clickmap/route.ts index d900103c9d..f33f7e28b6 100644 --- a/apps/backend/src/app/api/latest/analytics/heatmap/route.ts +++ b/apps/backend/src/app/api/latest/analytics/clickmap/route.ts @@ -2,12 +2,12 @@ import { type ClickmapClicksQueryResult, parseBoundedDateTime, runClickmapClicksQuery, - throwClickhouseHeatmapError, + throwClickhouseClickmapError, } from "@/lib/analytics-clickmap-query"; -import { verifyAnalyticsHeatmapToken } from "@/lib/analytics-heatmap-tokens"; +import { verifyAnalyticsClickmapToken } from "@/lib/analytics-clickmap-tokens"; import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { AnalyticsHeatmapResponseBodySchema, type AnalyticsHeatmapResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { AnalyticsClickmapResponseBodySchema, type AnalyticsClickmapResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; @@ -18,8 +18,8 @@ const ELEMENTS_CHAIN_LIMIT = 200; export const POST = createSmartRouteHandler({ metadata: { - summary: "Get page heatmap data", - description: "Returns click heatmap data for the current browser origin when authorized by a short-lived heatmap token.", + summary: "Get page clickmap data", + description: "Returns aggregated click data for the current browser origin when authorized by a short-lived clickmap token.", tags: ["Analytics"], hidden: true, }, @@ -30,7 +30,7 @@ export const POST = createSmartRouteHandler({ user: adaptSchema.optional(), }).defined(), body: yupObject({ - heatmap_token: yupString().defined(), + clickmap_token: yupString().defined(), origin: yupString().defined(), route_path: yupString().optional(), route_regex: yupString().optional(), @@ -48,16 +48,16 @@ export const POST = createSmartRouteHandler({ response: yupObject({ statusCode: yupNumber().oneOf([200]).defined(), bodyType: yupString().oneOf(["json"]).defined(), - body: AnalyticsHeatmapResponseBodySchema, + body: AnalyticsClickmapResponseBodySchema, }), handler: async ({ body }) => { - // The dashboard mint path is the feature gate for heatmap overlays. This + // The dashboard mint path is the feature gate for clickmap overlays. This // public read endpoint is authorized by the short-lived origin-bound token // below, so avoid app/user gates that can disagree with the launching // dashboard or anonymous customer pages. - const heatmapToken = await verifyAnalyticsHeatmapToken({ - token: body.heatmap_token, + const clickmapToken = await verifyAnalyticsClickmapToken({ + token: body.clickmap_token, origin: body.origin, }); @@ -67,18 +67,18 @@ export const POST = createSmartRouteHandler({ throw new StatusError(StatusError.BadRequest, "until must be after since"); } if (until.getTime() - since.getTime() > MAX_WINDOW_DAYS * ONE_DAY_MS) { - throw new StatusError(StatusError.BadRequest, `Heatmap window cannot exceed ${MAX_WINDOW_DAYS} days`); + throw new StatusError(StatusError.BadRequest, `Clickmap window cannot exceed ${MAX_WINDOW_DAYS} days`); } const client = getClickhouseAdminClientForMetrics(); let result: ClickmapClicksQueryResult; try { result = await runClickmapClicksQuery(client, { - projectId: heatmapToken.project_id, - branchId: heatmapToken.branch_id, + projectId: clickmapToken.project_id, + branchId: clickmapToken.branch_id, since, until, - origin: heatmapToken.origin, + origin: clickmapToken.origin, routePath: body.route_path, routeRegex: body.route_regex, urlPattern: body.url_pattern, @@ -92,16 +92,16 @@ export const POST = createSmartRouteHandler({ elementsChainLimit: ELEMENTS_CHAIN_LIMIT, }); } catch (error) { - throwClickhouseHeatmapError(error, { - captureLabel: "analytics-heatmap-clickhouse-fallback", + throwClickhouseClickmapError(error, { + captureLabel: "analytics-clickmap-clickhouse-fallback", routeRegex: body.route_regex, - context: { projectId: heatmapToken.project_id, branchId: heatmapToken.branch_id }, + context: { projectId: clickmapToken.project_id, branchId: clickmapToken.branch_id }, }); } // The public overlay only consumes routes/selectors/elements; per-user and // per-replay aggregates are intentionally not fetched here (no linkedLimit). - const responseBody: AnalyticsHeatmapResponse = { + const responseBody: AnalyticsClickmapResponse = { kind: "session_replay_clicks", cells: [], sampling: result.samplingPct / 100, diff --git a/apps/backend/src/app/api/latest/internal/analytics/heatmap-token/route.ts b/apps/backend/src/app/api/latest/internal/analytics/clickmap-token/route.ts similarity index 71% rename from apps/backend/src/app/api/latest/internal/analytics/heatmap-token/route.ts rename to apps/backend/src/app/api/latest/internal/analytics/clickmap-token/route.ts index ed66f77b54..c7b70e804d 100644 --- a/apps/backend/src/app/api/latest/internal/analytics/heatmap-token/route.ts +++ b/apps/backend/src/app/api/latest/internal/analytics/clickmap-token/route.ts @@ -1,6 +1,6 @@ -import { createAnalyticsHeatmapToken } from "@/lib/analytics-heatmap-tokens"; +import { createAnalyticsClickmapToken } from "@/lib/analytics-clickmap-tokens"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { AnalyticsHeatmapTokenResponseBodySchema } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { AnalyticsClickmapTokenResponseBodySchema } from "@stackframe/stack-shared/dist/interface/admin-metrics"; import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; export const POST = createSmartRouteHandler({ @@ -17,10 +17,10 @@ export const POST = createSmartRouteHandler({ response: yupObject({ statusCode: yupNumber().oneOf([200]).defined(), bodyType: yupString().oneOf(["json"]).defined(), - body: AnalyticsHeatmapTokenResponseBodySchema, + body: AnalyticsClickmapTokenResponseBodySchema, }), handler: async ({ auth, body }) => { - const token = await createAnalyticsHeatmapToken({ tenancy: auth.tenancy, origin: body.origin }); + const token = await createAnalyticsClickmapToken({ tenancy: auth.tenancy, origin: body.origin }); return { statusCode: 200, bodyType: "json", diff --git a/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts b/apps/backend/src/app/api/latest/internal/analytics/clickmap/route.ts similarity index 81% rename from apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts rename to apps/backend/src/app/api/latest/internal/analytics/clickmap/route.ts index d903d494af..189882f87f 100644 --- a/apps/backend/src/app/api/latest/internal/analytics/heatmap/route.ts +++ b/apps/backend/src/app/api/latest/internal/analytics/clickmap/route.ts @@ -1,16 +1,16 @@ import { - buildHourOfWeekHeatmapCells, + buildHourOfWeekClickmapCells, type ClickmapClicksQueryResult, formatClickhouseDateTimeParam, parseBoundedDateTime, runClickmapClicksQuery, - throwClickhouseHeatmapError, + throwClickhouseClickmapError, } from "@/lib/analytics-clickmap-query"; import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse"; import type { Tenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { AnalyticsHeatmapResponseBodySchema, type AnalyticsHeatmapResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { AnalyticsClickmapResponseBodySchema, type AnalyticsClickmapResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import yup from "yup"; @@ -23,7 +23,7 @@ const LINKED_LIMIT = 25; const ELEMENTS_CHAIN_LIMIT = 100; const ONE_DAY_MS = 24 * 60 * 60 * 1000; -const heatmapRequestBodySchema = yupObject({ +const clickmapRequestBodySchema = yupObject({ kind: yupString().oneOf(["team_user_hour_of_week", "session_replay_clicks"]).defined(), member_user_ids: yupArray(yupString().defined()).optional().default([]).max(MAX_TEAM_MEMBER_IDS), route_path: yupString().optional(), @@ -39,13 +39,13 @@ const heatmapRequestBodySchema = yupObject({ until: yupString().defined(), }).defined(); -type HeatmapRequestBody = yup.InferType; +type ClickmapRequestBody = yup.InferType; -function emptyHeatmapResponse(kind: AnalyticsHeatmapResponse["kind"], cells: AnalyticsHeatmapResponse["cells"]): AnalyticsHeatmapResponse { +function emptyClickmapResponse(kind: AnalyticsClickmapResponse["kind"], cells: AnalyticsClickmapResponse["cells"]): AnalyticsClickmapResponse { return { kind, cells, sampling: 1, routes: [], users: [], replays: [], selectors: [], elements: [] }; } -async function handleClickHeatmap(tenancy: Tenancy, body: HeatmapRequestBody, since: Date, until: Date): Promise { +async function handleClickClickmap(tenancy: Tenancy, body: ClickmapRequestBody, since: Date, until: Date): Promise { const client = getClickhouseAdminClientForMetrics(); let result: ClickmapClicksQueryResult; try { @@ -68,8 +68,8 @@ async function handleClickHeatmap(tenancy: Tenancy, body: HeatmapRequestBody, si linkedLimit: LINKED_LIMIT, }); } catch (error) { - throwClickhouseHeatmapError(error, { - captureLabel: "internal-analytics-heatmap-clickhouse-fallback", + throwClickhouseClickmapError(error, { + captureLabel: "internal-analytics-clickmap-clickhouse-fallback", routeRegex: body.route_regex, context: { projectId: tenancy.project.id, branchId: tenancy.branchId, kind: body.kind }, }); @@ -124,9 +124,9 @@ async function handleClickHeatmap(tenancy: Tenancy, body: HeatmapRequestBody, si }; } -async function handleTeamHourOfWeek(tenancy: Tenancy, body: HeatmapRequestBody, since: Date, until: Date): Promise { +async function handleTeamHourOfWeek(tenancy: Tenancy, body: ClickmapRequestBody, since: Date, until: Date): Promise { if (body.member_user_ids.length === 0) { - return emptyHeatmapResponse(body.kind, buildHourOfWeekHeatmapCells([])); + return emptyClickmapResponse(body.kind, buildHourOfWeekClickmapCells([])); } const client = getClickhouseAdminClientForMetrics(); @@ -153,10 +153,10 @@ async function handleTeamHourOfWeek(tenancy: Tenancy, body: HeatmapRequestBody, format: "JSONEachRow", }); const rows: { weekday: number | string, hour: number | string, value: number | string }[] = await result.json(); - return emptyHeatmapResponse(body.kind, buildHourOfWeekHeatmapCells(rows)); + return emptyClickmapResponse(body.kind, buildHourOfWeekClickmapCells(rows)); } catch (error) { - throwClickhouseHeatmapError(error, { - captureLabel: "internal-analytics-heatmap-clickhouse-fallback", + throwClickhouseClickmapError(error, { + captureLabel: "internal-analytics-clickmap-clickhouse-fallback", context: { projectId: tenancy.project.id, branchId: tenancy.branchId, kind: body.kind }, }); } @@ -169,12 +169,12 @@ export const POST = createSmartRouteHandler({ type: adminAuthTypeSchema.defined(), tenancy: adaptSchema.defined(), }), - body: heatmapRequestBodySchema, + body: clickmapRequestBodySchema, }), response: yupObject({ statusCode: yupNumber().oneOf([200]).defined(), bodyType: yupString().oneOf(["json"]).defined(), - body: AnalyticsHeatmapResponseBodySchema, + body: AnalyticsClickmapResponseBodySchema, }), handler: async ({ auth, body }) => { const since = parseBoundedDateTime(body.since, "since"); @@ -183,11 +183,11 @@ export const POST = createSmartRouteHandler({ throw new StatusError(StatusError.BadRequest, "until must be after since"); } if (until.getTime() - since.getTime() > MAX_WINDOW_DAYS * ONE_DAY_MS) { - throw new StatusError(StatusError.BadRequest, `Heatmap window cannot exceed ${MAX_WINDOW_DAYS} days`); + throw new StatusError(StatusError.BadRequest, `Query window cannot exceed ${MAX_WINDOW_DAYS} days`); } const responseBody = body.kind === "session_replay_clicks" - ? await handleClickHeatmap(auth.tenancy, body, since, until) + ? await handleClickClickmap(auth.tenancy, body, since, until) : await handleTeamHourOfWeek(auth.tenancy, body, since, until); return { statusCode: 200, bodyType: "json", body: responseBody } as const; diff --git a/apps/backend/src/app/api/latest/internal/user-activity/route.tsx b/apps/backend/src/app/api/latest/internal/user-activity/route.tsx index 8243429a06..11afd219c9 100644 --- a/apps/backend/src/app/api/latest/internal/user-activity/route.tsx +++ b/apps/backend/src/app/api/latest/internal/user-activity/route.tsx @@ -5,7 +5,7 @@ import { UserActivityResponseBodySchema } from "@stackframe/stack-shared/dist/in import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; import { HexclaveAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors"; -// Per-user activity heatmap window. Sized to match the 22×16 dashboard grid +// Per-user activity clickmap window. Sized to match the 22×16 dashboard grid // so every cell maps to exactly one day and we never truncate or pad awkwardly // on the client. Bump both sides if you want a longer/shorter window. const USER_ACTIVITY_WINDOW_DAYS = 22 * 16; diff --git a/apps/backend/src/lib/analytics-clickmap-query.test.ts b/apps/backend/src/lib/analytics-clickmap-query.test.ts index 7c9b9b91f7..d77e3abae5 100644 --- a/apps/backend/src/lib/analytics-clickmap-query.test.ts +++ b/apps/backend/src/lib/analytics-clickmap-query.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildClickmapUrlLikePattern, - buildHourOfWeekHeatmapCells, + buildHourOfWeekClickmapCells, clampClickmapSampling, getClickmapOriginFilter, getClickmapOriginParams, @@ -14,7 +14,7 @@ import { describe("analytics clickmap query helpers", () => { it("pads sparse hour-of-week rows into a complete 7x24 grid", () => { - const cells = buildHourOfWeekHeatmapCells([ + const cells = buildHourOfWeekClickmapCells([ { weekday: "1", hour: "0", value: "3" }, { weekday: 7, hour: 23, value: 9 }, ]); @@ -44,7 +44,7 @@ describe("analytics clickmap query helpers", () => { }); it("ignores invalid ClickHouse bucket rows", () => { - const cells = buildHourOfWeekHeatmapCells([ + const cells = buildHourOfWeekClickmapCells([ { weekday: 0, hour: 12, value: 10 }, { weekday: 1, hour: 24, value: 10 }, { weekday: 2, hour: 3, value: 4 }, diff --git a/apps/backend/src/lib/analytics-clickmap-query.ts b/apps/backend/src/lib/analytics-clickmap-query.ts index a61b794669..4d715ba5c3 100644 --- a/apps/backend/src/lib/analytics-clickmap-query.ts +++ b/apps/backend/src/lib/analytics-clickmap-query.ts @@ -4,8 +4,8 @@ import { HexclaveAssertionError, StatusError, captureError } from "@stackframe/s // Canonical owner of the ClickHouse clickmap query: filter/param builders, the // shared aggregate queries, and result scaling. Both the admin route -// (`internal/analytics/heatmap`) and the origin-token public route -// (`analytics/heatmap`) drive their `session_replay_clicks` results through here +// (`internal/analytics/clickmap`) and the origin-token public route +// (`analytics/clickmap`) drive their `session_replay_clicks` results through here // so the SQL and sampling math live in exactly one place. const CLICKMAP_TABLE = "analytics_internal.clickmap_events"; @@ -35,12 +35,12 @@ export function isClickhouseRegexpError(error: ClickHouseError): boolean { } /** - * Translate a heatmap query failure into the right StatusError. A failed + * Translate a clickmap query failure into the right StatusError. A failed * user-supplied route regex becomes a 400; any other ClickHouse failure is * captured and surfaced as a generic 503; non-ClickHouse errors are rethrown * untouched. Always throws. */ -export function throwClickhouseHeatmapError(error: unknown, options: { +export function throwClickhouseClickmapError(error: unknown, options: { captureLabel: string, routeRegex?: string, context: Record, @@ -52,10 +52,10 @@ export function throwClickhouseHeatmapError(error: unknown, options: { throw new StatusError(StatusError.BadRequest, "Invalid route regex"); } captureError(options.captureLabel, new HexclaveAssertionError( - "Failed to load analytics heatmap due to ClickHouse query failure.", + "Failed to load analytics data due to ClickHouse query failure.", { cause: error, ...options.context }, )); - throw new StatusError(StatusError.ServiceUnavailable, "Analytics heatmap is temporarily unavailable."); + throw new StatusError(StatusError.ServiceUnavailable, "Analytics data is temporarily unavailable."); } // --------------------------------------------------------------------------- @@ -133,7 +133,7 @@ export function getClickmapOriginParams(origin: string): { }; } -// Exclude clicks landing on the in-page dev tool / heatmap overlay itself. The +// Exclude clicks landing on the in-page dev tool / clickmap overlay itself. The // dev-tool identity comes from shared constants so this SQL can never silently // drift from the actual DOM markers (see `utils/dev-tool`). export function getClickmapSystemElementFilter(): string { @@ -154,7 +154,7 @@ export function clampClickmapSampling(value: number | undefined): number { return value; } -export function buildHourOfWeekHeatmapCells(rows: { weekday: number | string, hour: number | string, value: number | string }[]) { +export function buildHourOfWeekClickmapCells(rows: { weekday: number | string, hour: number | string, value: number | string }[]) { const byCell = new Map(); for (const row of rows) { const weekday = Number(row.weekday); diff --git a/apps/backend/src/lib/analytics-heatmap-tokens.test.ts b/apps/backend/src/lib/analytics-clickmap-tokens.test.ts similarity index 54% rename from apps/backend/src/lib/analytics-heatmap-tokens.test.ts rename to apps/backend/src/lib/analytics-clickmap-tokens.test.ts index 2b4aa6f68e..919c818fe2 100644 --- a/apps/backend/src/lib/analytics-heatmap-tokens.test.ts +++ b/apps/backend/src/lib/analytics-clickmap-tokens.test.ts @@ -1,34 +1,34 @@ import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { signJWT } from "@stackframe/stack-shared/dist/utils/jwt"; import { describe, expect, it } from "vitest"; -import { normalizeAnalyticsHeatmapOrigin, verifyAnalyticsHeatmapToken } from "./analytics-heatmap-tokens"; +import { normalizeAnalyticsClickmapOrigin, verifyAnalyticsClickmapToken } from "./analytics-clickmap-tokens"; -describe("analytics heatmap token helpers", () => { +describe("analytics clickmap token helpers", () => { it("normalizes a trusted-domain URL to its origin", () => { - expect(normalizeAnalyticsHeatmapOrigin("https://example.com/dashboard?x=1")).toMatchInlineSnapshot(`"https://example.com"`); + expect(normalizeAnalyticsClickmapOrigin("https://example.com/dashboard?x=1")).toMatchInlineSnapshot(`"https://example.com"`); }); it("rejects non-HTTP origins", () => { - expect(() => normalizeAnalyticsHeatmapOrigin("javascript:alert(1)")).toThrow(StatusError); + expect(() => normalizeAnalyticsClickmapOrigin("javascript:alert(1)")).toThrow(StatusError); }); - it("returns the project encoded in a valid heatmap token", async () => { + it("returns the project encoded in a valid clickmap token", async () => { const token = await signJWT({ - issuer: "hexclave:analytics:heatmap", - audience: "hexclave:analytics:heatmap-overlay", + issuer: "hexclave:analytics:clickmap", + audience: "hexclave:analytics:clickmap-overlay", expirationTime: "24h", payload: { - kind: "analytics_heatmap_overlay", - scope: "heatmap:read", + kind: "analytics_clickmap_overlay", + scope: "clickmap:read", project_id: "internal", branch_id: "main", origin: "http://localhost:8101", }, }); - const payload = await verifyAnalyticsHeatmapToken({ + const payload = await verifyAnalyticsClickmapToken({ token, - origin: "http://localhost:8101/projects/internal/analytics/heatmaps", + origin: "http://localhost:8101/projects/internal/analytics/clickmaps", }); expect({ @@ -40,10 +40,10 @@ describe("analytics heatmap token helpers", () => { }).toMatchInlineSnapshot(` { "branch_id": "main", - "kind": "analytics_heatmap_overlay", + "kind": "analytics_clickmap_overlay", "origin": "http://localhost:8101", "project_id": "internal", - "scope": "heatmap:read", + "scope": "clickmap:read", } `); }); diff --git a/apps/backend/src/lib/analytics-clickmap-tokens.ts b/apps/backend/src/lib/analytics-clickmap-tokens.ts new file mode 100644 index 0000000000..924f5803eb --- /dev/null +++ b/apps/backend/src/lib/analytics-clickmap-tokens.ts @@ -0,0 +1,104 @@ +import type { Tenancy } from "@/lib/tenancies"; +import { validateRedirectUrl } from "@/lib/redirect-urls"; +import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { signJWT, verifyJWT } from "@stackframe/stack-shared/dist/utils/jwt"; +import { yupValidate } from "@stackframe/stack-shared/dist/schema-fields"; +import { JOSEError } from "jose/errors"; +import { ValidationError } from "yup"; + +const CLICKMAP_TOKEN_ISSUER = "hexclave:analytics:clickmap"; +const CLICKMAP_TOKEN_AUDIENCE = "hexclave:analytics:clickmap-overlay"; +const CLICKMAP_TOKEN_KIND = "analytics_clickmap_overlay"; +const CLICKMAP_TOKEN_SCOPE = "clickmap:read"; +export const CLICKMAP_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; + +const AnalyticsClickmapTokenPayloadSchema = yupObject({ + kind: yupString().oneOf([CLICKMAP_TOKEN_KIND]).defined(), + scope: yupString().oneOf([CLICKMAP_TOKEN_SCOPE]).defined(), + project_id: yupString().defined(), + branch_id: yupString().defined(), + origin: yupString().defined(), +}).defined(); + +export type AnalyticsClickmapTokenPayload = { + kind: typeof CLICKMAP_TOKEN_KIND, + scope: typeof CLICKMAP_TOKEN_SCOPE, + project_id: string, + branch_id: string, + origin: string, +}; + +export function normalizeAnalyticsClickmapOrigin(origin: string): string { + let url: URL; + try { + url = new URL(origin); + } catch { + throw new StatusError(StatusError.BadRequest, "Invalid clickmap origin"); + } + + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new StatusError(StatusError.BadRequest, "Clickmap origin must be an HTTP(S) origin"); + } + + return url.origin; +} + +export function validateAnalyticsClickmapOrigin(tenancy: Tenancy, origin: string): string { + const normalizedOrigin = normalizeAnalyticsClickmapOrigin(origin); + if (!validateRedirectUrl(`${normalizedOrigin}/`, tenancy)) { + throw new StatusError(StatusError.Forbidden, "Clickmap origin is not a trusted domain for this project"); + } + return normalizedOrigin; +} + +export async function createAnalyticsClickmapToken(options: { + tenancy: Tenancy, + origin: string, +}): Promise<{ token: string, origin: string, expiresAtMillis: number }> { + const origin = validateAnalyticsClickmapOrigin(options.tenancy, options.origin); + const expiresAtMillis = Date.now() + CLICKMAP_TOKEN_TTL_MS; + const token = await signJWT({ + issuer: CLICKMAP_TOKEN_ISSUER, + audience: CLICKMAP_TOKEN_AUDIENCE, + expirationTime: `${CLICKMAP_TOKEN_TTL_MS / 1000}s`, + payload: { + kind: CLICKMAP_TOKEN_KIND, + scope: CLICKMAP_TOKEN_SCOPE, + project_id: options.tenancy.project.id, + branch_id: options.tenancy.branchId, + origin, + } satisfies AnalyticsClickmapTokenPayload, + }); + return { token, origin, expiresAtMillis }; +} + +export async function verifyAnalyticsClickmapToken(options: { + token: string, + origin: string, +}): Promise { + const origin = normalizeAnalyticsClickmapOrigin(options.origin); + let payload: AnalyticsClickmapTokenPayload; + try { + const verified = await verifyJWT({ allowedIssuers: [CLICKMAP_TOKEN_ISSUER], jwt: options.token }); + // verifyJWT only constrains the issuer, so also require the audience to match + // — otherwise a validly-signed token minted for a different audience could pass. + if (verified.aud !== CLICKMAP_TOKEN_AUDIENCE) { + throw new StatusError(StatusError.Unauthorized, "Invalid or expired clickmap token"); + } + payload = await yupValidate(AnalyticsClickmapTokenPayloadSchema, verified, { abortEarly: false }); + } catch (error) { + // Only expected JWT/validation failures are auth errors; rethrow anything + // unexpected (e.g. backend faults) so they aren't misreported as bad credentials. + if (error instanceof StatusError) throw error; + if (error instanceof JOSEError || error instanceof ValidationError) { + throw new StatusError(StatusError.Unauthorized, "Invalid or expired clickmap token"); + } + throw error; + } + + if (payload.origin !== origin) { + throw new StatusError(StatusError.Forbidden, "Clickmap token origin does not match this page"); + } + return payload; +} diff --git a/apps/backend/src/lib/analytics-heatmap-tokens.ts b/apps/backend/src/lib/analytics-heatmap-tokens.ts deleted file mode 100644 index 80dc1ce1fc..0000000000 --- a/apps/backend/src/lib/analytics-heatmap-tokens.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { Tenancy } from "@/lib/tenancies"; -import { validateRedirectUrl } from "@/lib/redirect-urls"; -import { yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; -import { signJWT, verifyJWT } from "@stackframe/stack-shared/dist/utils/jwt"; -import { yupValidate } from "@stackframe/stack-shared/dist/schema-fields"; -import { JOSEError } from "jose/errors"; -import { ValidationError } from "yup"; - -const HEATMAP_TOKEN_ISSUER = "hexclave:analytics:heatmap"; -const HEATMAP_TOKEN_AUDIENCE = "hexclave:analytics:heatmap-overlay"; -const HEATMAP_TOKEN_KIND = "analytics_heatmap_overlay"; -const HEATMAP_TOKEN_SCOPE = "heatmap:read"; -export const HEATMAP_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; - -const AnalyticsHeatmapTokenPayloadSchema = yupObject({ - kind: yupString().oneOf([HEATMAP_TOKEN_KIND]).defined(), - scope: yupString().oneOf([HEATMAP_TOKEN_SCOPE]).defined(), - project_id: yupString().defined(), - branch_id: yupString().defined(), - origin: yupString().defined(), -}).defined(); - -export type AnalyticsHeatmapTokenPayload = { - kind: typeof HEATMAP_TOKEN_KIND, - scope: typeof HEATMAP_TOKEN_SCOPE, - project_id: string, - branch_id: string, - origin: string, -}; - -export function normalizeAnalyticsHeatmapOrigin(origin: string): string { - let url: URL; - try { - url = new URL(origin); - } catch { - throw new StatusError(StatusError.BadRequest, "Invalid heatmap origin"); - } - - if (url.protocol !== "https:" && url.protocol !== "http:") { - throw new StatusError(StatusError.BadRequest, "Heatmap origin must be an HTTP(S) origin"); - } - - return url.origin; -} - -export function validateAnalyticsHeatmapOrigin(tenancy: Tenancy, origin: string): string { - const normalizedOrigin = normalizeAnalyticsHeatmapOrigin(origin); - if (!validateRedirectUrl(`${normalizedOrigin}/`, tenancy)) { - throw new StatusError(StatusError.Forbidden, "Heatmap origin is not a trusted domain for this project"); - } - return normalizedOrigin; -} - -export async function createAnalyticsHeatmapToken(options: { - tenancy: Tenancy, - origin: string, -}): Promise<{ token: string, origin: string, expiresAtMillis: number }> { - const origin = validateAnalyticsHeatmapOrigin(options.tenancy, options.origin); - const expiresAtMillis = Date.now() + HEATMAP_TOKEN_TTL_MS; - const token = await signJWT({ - issuer: HEATMAP_TOKEN_ISSUER, - audience: HEATMAP_TOKEN_AUDIENCE, - expirationTime: `${HEATMAP_TOKEN_TTL_MS / 1000}s`, - payload: { - kind: HEATMAP_TOKEN_KIND, - scope: HEATMAP_TOKEN_SCOPE, - project_id: options.tenancy.project.id, - branch_id: options.tenancy.branchId, - origin, - } satisfies AnalyticsHeatmapTokenPayload, - }); - return { token, origin, expiresAtMillis }; -} - -export async function verifyAnalyticsHeatmapToken(options: { - token: string, - origin: string, -}): Promise { - const origin = normalizeAnalyticsHeatmapOrigin(options.origin); - let payload: AnalyticsHeatmapTokenPayload; - try { - const verified = await verifyJWT({ allowedIssuers: [HEATMAP_TOKEN_ISSUER], jwt: options.token }); - // verifyJWT only constrains the issuer, so also require the audience to match - // — otherwise a validly-signed token minted for a different audience could pass. - if (verified.aud !== HEATMAP_TOKEN_AUDIENCE) { - throw new StatusError(StatusError.Unauthorized, "Invalid or expired heatmap token"); - } - payload = await yupValidate(AnalyticsHeatmapTokenPayloadSchema, verified, { abortEarly: false }); - } catch (error) { - // Only expected JWT/validation failures are auth errors; rethrow anything - // unexpected (e.g. backend faults) so they aren't misreported as bad credentials. - if (error instanceof StatusError) throw error; - if (error instanceof JOSEError || error instanceof ValidationError) { - throw new StatusError(StatusError.Unauthorized, "Invalid or expired heatmap token"); - } - throw error; - } - - if (payload.origin !== origin) { - throw new StatusError(StatusError.Forbidden, "Heatmap token origin does not match this page"); - } - return payload; -} diff --git a/apps/backend/src/route-handlers/smart-route-handler.tsx b/apps/backend/src/route-handlers/smart-route-handler.tsx index acc140964b..3774dff2bd 100644 --- a/apps/backend/src/route-handlers/smart-route-handler.tsx +++ b/apps/backend/src/route-handlers/smart-route-handler.tsx @@ -105,8 +105,8 @@ export function handleApiRequest(handler: (req: NextRequest, options: any, reque // request duration warning const allowedLongRequestPaths = [ "/api/latest/internal/email-queue-step", - "/api/latest/analytics/heatmap", - "/api/latest/internal/analytics/heatmap", + "/api/latest/analytics/clickmap", + "/api/latest/internal/analytics/clickmap", "/api/latest/internal/analytics/query", "/api/latest/ai/query/stream", "/api/latest/ai/query/generate", diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx index 1a04190ad1..6460a169c8 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx @@ -720,7 +720,7 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate return Math.max(0, Math.log(100 * likelihoodRatio)); }; - // Heatmap-style coloring: population-normalized user concentration, with a + // Clickmap-style coloring: population-normalized user concentration, with a // confidence lower bound so tiny samples don't make a country look too strong. const numericColorValues = countries.features .map((country) => { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/page-client.tsx similarity index 82% rename from apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx rename to apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/page-client.tsx index 4661041ceb..d44f729c9c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/page-client.tsx @@ -31,27 +31,23 @@ import { useDataSource, type DataGridColumnDef, } from "@stackframe/dashboard-ui-components"; -import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import type { AnalyticsClickmapDevice, AnalyticsClickmapResponse, AnalyticsClickmapTokenResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; import { - getProjectHeatmapOriginStorageKey, - getProjectHeatmapTokenStorageKey, - HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY, - HEATMAP_OVERLAY_PROJECT_STORAGE_KEY, - HEATMAP_OVERLAY_TOKEN_STORAGE_KEY, - HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT, -} from "@stackframe/stack-shared/dist/utils/analytics-heatmap-overlay"; + CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY, + CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, +} from "@stackframe/stack-shared/dist/utils/analytics-clickmap-overlay"; import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import { ArrowRight, GlobeHemisphereWest } from "@phosphor-icons/react"; import { useEffect, useMemo, useState } from "react"; -type HeatmapOrigin = { +type ClickmapOrigin = { id: string, origin: string, }; type RangeKey = "24h" | "7d" | "30d"; -type DeviceFilterKey = "all" | AnalyticsHeatmapDevice; +type DeviceFilterKey = "all" | AnalyticsClickmapDevice; const RANGE_MS: Record = { "24h": 24 * 60 * 60 * 1000, @@ -81,7 +77,7 @@ function truncateMiddle(value: string, max: number): string { return `${value.slice(0, half)}…${value.slice(value.length - half)}`; } -type TopElementRow = AnalyticsHeatmapResponse["elements"][number]; +type TopElementRow = AnalyticsClickmapResponse["elements"][number]; const getTopElementRowId = (row: TopElementRow): string => row.elements_chain; @@ -142,14 +138,14 @@ function TopElementsPreview(props: { const [urlPattern, setUrlPattern] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [data, setData] = useState(null); + const [data, setData] = useState(null); useEffect(() => { let cancelled = false; const handle = setTimeout(() => { const until = new Date(); const since = new Date(until.getTime() - RANGE_MS[range]); - const options: Parameters[0] = { + const options: Parameters[0] = { kind: "session_replay_clicks", since: since.toISOString(), until: until.toISOString(), @@ -164,7 +160,7 @@ function TopElementsPreview(props: { } setLoading(true); setError(null); - adminApp.getAnalyticsHeatmap(options) + adminApp.getAnalyticsClickmap(options) .then((response) => { if (cancelled) return; setData(response); @@ -294,29 +290,23 @@ function normalizeOrigin(baseUrl: string): string | null { } } -function createConsoleSnippet(token: string, origin: string, projectId: string): string { +// The clickmap token is a self-describing JWT (its payload carries the project +// and origin it was minted for), so the snippet only has to hand over the token +// itself — the in-page overlay derives everything else from it. +function createConsoleSnippet(token: string): string { return [ - `sessionStorage.setItem(${JSON.stringify(HEATMAP_OVERLAY_PROJECT_STORAGE_KEY)}, ${JSON.stringify(projectId)});`, - `sessionStorage.setItem(${JSON.stringify(getProjectHeatmapOriginStorageKey(projectId))}, ${JSON.stringify(origin)});`, - `sessionStorage.setItem(${JSON.stringify(getProjectHeatmapTokenStorageKey(projectId))}, ${JSON.stringify(token)});`, - `sessionStorage.setItem(${JSON.stringify(HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY)}, ${JSON.stringify(origin)});`, - `sessionStorage.setItem(${JSON.stringify(HEATMAP_OVERLAY_TOKEN_STORAGE_KEY)}, ${JSON.stringify(token)});`, - `window.dispatchEvent(new Event(${JSON.stringify(HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT)}));`, - `console.info("Hexclave clickmap toolbar enabled for this tab.");`, + `sessionStorage.setItem(${JSON.stringify(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY)}, ${JSON.stringify(token)});`, + `window.dispatchEvent(new Event(${JSON.stringify(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT)}));`, ].join("\n"); } -function installHeatmapTokenForCurrentOrigin(token: AnalyticsHeatmapTokenResponse, projectId: string): boolean { +function installClickmapTokenForCurrentOrigin(token: AnalyticsClickmapTokenResponse): boolean { if (token.origin !== window.location.origin) { return false; } try { - window.sessionStorage.setItem(HEATMAP_OVERLAY_PROJECT_STORAGE_KEY, projectId); - window.sessionStorage.setItem(getProjectHeatmapOriginStorageKey(projectId), token.origin); - window.sessionStorage.setItem(getProjectHeatmapTokenStorageKey(projectId), token.token); - window.sessionStorage.setItem(HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY, token.origin); - window.sessionStorage.setItem(HEATMAP_OVERLAY_TOKEN_STORAGE_KEY, token.token); - window.dispatchEvent(new Event(HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT)); + window.sessionStorage.setItem(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY, token.token); + window.dispatchEvent(new Event(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT)); return true; } catch { window.alert("Could not enable the clickmap toolbar in this tab. Copy the snippet and paste it in the console instead."); @@ -324,14 +314,13 @@ function installHeatmapTokenForCurrentOrigin(token: AnalyticsHeatmapTokenRespons } } -function HeatmapTokenDialog(props: { - origin: HeatmapOrigin | null, - projectId: string, - token: AnalyticsHeatmapTokenResponse | null, +function ClickmapTokenDialog(props: { + origin: ClickmapOrigin | null, + token: AnalyticsClickmapTokenResponse | null, open: boolean, onOpenChange: (open: boolean) => void, }) { - const snippet = props.token == null ? "" : createConsoleSnippet(props.token.token, props.token.origin, props.projectId); + const snippet = props.token == null ? "" : createConsoleSnippet(props.token.token); return ( @@ -379,12 +368,12 @@ export default function PageClient() { const project = adminApp.useProject(); const config = project.useConfig(); const [dialogOpen, setDialogOpen] = useState(false); - const [selectedOrigin, setSelectedOrigin] = useState(null); - const [token, setToken] = useState(null); + const [selectedOrigin, setSelectedOrigin] = useState(null); + const [token, setToken] = useState(null); const [customOrigin, setCustomOrigin] = useState("http://localhost:8101"); const origins = useMemo(() => { - const byOrigin = new Map(); + const byOrigin = new Map(); for (const [id, domain] of typedEntries(config.domains.trustedDomains)) { if (domain.baseUrl == null) { continue; @@ -398,13 +387,13 @@ export default function PageClient() { return Array.from(byOrigin.values()).sort((a, b) => stringCompare(a.origin, b.origin)); }, [config.domains.trustedDomains]); - async function showHeatmap(origin: HeatmapOrigin) { + async function showClickmap(origin: ClickmapOrigin) { setSelectedOrigin(origin); setToken(null); setDialogOpen(true); - let created: AnalyticsHeatmapTokenResponse; + let created: AnalyticsClickmapTokenResponse; try { - created = await adminApp.createAnalyticsHeatmapToken({ origin: origin.origin }); + created = await adminApp.createAnalyticsClickmapToken({ origin: origin.origin }); } catch (error) { // Token creation failed (network error, expired session, invalid origin, // etc.); close the dialog so it doesn't hang on "Creating..." and let @@ -414,9 +403,9 @@ export default function PageClient() { throw error; } setToken(created); - const installedInCurrentTab = installHeatmapTokenForCurrentOrigin(created, adminApp.projectId); + const installedInCurrentTab = installClickmapTokenForCurrentOrigin(created); try { - await navigator.clipboard.writeText(createConsoleSnippet(created.token, created.origin, adminApp.projectId)); + await navigator.clipboard.writeText(createConsoleSnippet(created.token)); toast({ title: installedInCurrentTab ? "Clickmap toolbar enabled" : "Snippet copied to clipboard" }); } catch { // Clipboard access can be denied (e.g. lost user-gesture after the @@ -441,7 +430,7 @@ export default function PageClient() {
@@ -533,7 +533,7 @@ function HourOfWeekHeatmap({ rows, hasAnyEvent }: { rows: HeatmapRow[], hasAnyEv
{value === 0 ? "No active users" - : `${value.toFixed(value < 10 ? 1 : 0)} active users over ${HEATMAP_WEEKS} weeks`} + : `${value.toFixed(value < 10 ? 1 : 0)} active users over ${CLICKMAP_WEEKS} weeks`}
@@ -544,14 +544,14 @@ function HourOfWeekHeatmap({ rows, hasAnyEvent }: { rows: HeatmapRow[], hasAnyEv
- + )} ); } -function HeatmapLegend({ max }: { max: number }) { +function ClickmapLegend({ max }: { max: number }) { if (max <= 0) return null; const stops = [0.0, 0.25, 0.5, 0.75, 1.0]; return ( diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx index 2f1798f9cd..dd735aad96 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx @@ -1621,7 +1621,7 @@ const ACTIVITY_WEEKDAY_LABELS = [ { label: "", ariaLabel: null }, ] as const; -// Activity heatmap color ramp. Indexed by 0 = no activity, 1..4 = increasing +// Activity clickmap color ramp. Indexed by 0 = no activity, 1..4 = increasing // log-scaled intensity based on the user's own max activity over the window. // Tailwind needs the exact class strings at build time, so we keep them // enumerated here rather than building them dynamically. diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index 469592f3e3..91442d0efd 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -394,7 +394,7 @@ export const ALL_APPS_FRONTEND = { navigationItems: [ { displayName: "Tables", href: "./tables" }, { displayName: "Replays", href: "../session-replays" }, - { displayName: "Clickmaps", href: "./heatmaps" }, + { displayName: "Clickmaps", href: "./clickmaps" }, { displayName: "Queries", href: "./queries" }, ], screenshots: [], diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal-user-activity.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal-user-activity.test.ts index c5894e6b21..61dcf3f01f 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal-user-activity.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal-user-activity.test.ts @@ -8,7 +8,7 @@ import { Auth, Project, niceBackendFetch } from "../../../backend-helpers"; // When that constant changes, bump this one too. const USER_ACTIVITY_WINDOW_DAYS = 22 * 16; -it("should return an empty activity heatmap for an unknown user", async ({ expect }) => { +it("should return an empty activity clickmap for an unknown user", async ({ expect }) => { await Project.createAndSwitch({ config: { magic_link_enabled: true, diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index 943263da70..9efd99972f 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -6,7 +6,7 @@ import type { MoneyAmount } from "../utils/currency-constants"; import type { Json } from "../utils/json"; import { Result } from "../utils/results"; import { urlString } from "../utils/urls"; -import type { AnalyticsHeatmapDevice, AnalyticsHeatmapKind, AnalyticsHeatmapResponse, AnalyticsHeatmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "./admin-metrics"; +import type { AnalyticsClickmapDevice, AnalyticsClickmapKind, AnalyticsClickmapResponse, AnalyticsClickmapTokenResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "./admin-metrics"; import type { AnalyticsQueryOptions, AnalyticsQueryResponse } from "./crud/analytics"; import { EmailOutboxCrud } from "./crud/email-outbox"; import { InternalEmailsCrud } from "./crud/emails"; @@ -402,23 +402,23 @@ export class HexclaveAdminInterface extends HexclaveServerInterface { return (await response.json()) as UserActivityResponse; } - async getAnalyticsHeatmap(options: { - kind: AnalyticsHeatmapKind, + async getAnalyticsClickmap(options: { + kind: AnalyticsClickmapKind, member_user_ids?: string[], route_path?: string, route_regex?: string, url_pattern?: string, user_id?: string, replay_id?: string, - device?: AnalyticsHeatmapDevice, + device?: AnalyticsClickmapDevice, viewport_width_min?: number, viewport_width_max?: number, sampling?: number, since: string, until: string, - }): Promise { + }): Promise { const response = await this.sendAdminRequest( - "/internal/analytics/heatmap", + "/internal/analytics/clickmap", { method: "POST", headers: { "content-type": "application/json" }, @@ -426,14 +426,14 @@ export class HexclaveAdminInterface extends HexclaveServerInterface { }, null, ); - return (await response.json()) as AnalyticsHeatmapResponse; + return (await response.json()) as AnalyticsClickmapResponse; } - async createAnalyticsHeatmapToken(options: { + async createAnalyticsClickmapToken(options: { origin: string, - }): Promise { + }): Promise { const response = await this.sendAdminRequest( - "/internal/analytics/heatmap-token", + "/internal/analytics/clickmap-token", { method: "POST", headers: { "content-type": "application/json" }, @@ -441,7 +441,7 @@ export class HexclaveAdminInterface extends HexclaveServerInterface { }, null, ); - return (await response.json()) as AnalyticsHeatmapTokenResponse; + return (await response.json()) as AnalyticsClickmapTokenResponse; } async getMetricsUserCounts(): Promise { diff --git a/packages/stack-shared/src/interface/admin-metrics.ts b/packages/stack-shared/src/interface/admin-metrics.ts index 8f35307f29..24b24344e4 100644 --- a/packages/stack-shared/src/interface/admin-metrics.ts +++ b/packages/stack-shared/src/interface/admin-metrics.ts @@ -172,31 +172,31 @@ export const MetricsRecentUserSchema = yupObject({ last_active_at_millis: yupNumber().nullable().defined(), }).noUnknown(false).defined(); -// Per-user activity heatmap — a simple list of daily event counts for a single +// Per-user activity clickmap — a simple list of daily event counts for a single // user. Backed by ClickHouse `analytics_internal.events` filtered by user_id, // project_id, and branch_id. See `/internal/user-activity` on the backend. export const UserActivityResponseBodySchema = yupObject({ data_points: MetricsDataPointsSchema, }).defined(); -export const AnalyticsHeatmapKindSchema = yupString().oneOf(["team_user_hour_of_week", "session_replay_clicks"]).defined(); -export const AnalyticsHeatmapDeviceSchema = yupString().oneOf(["tv", "widescreen", "desktop", "laptop", "tablet", "mobile"]).defined(); +export const AnalyticsClickmapKindSchema = yupString().oneOf(["team_user_hour_of_week", "session_replay_clicks"]).defined(); +export const AnalyticsClickmapDeviceSchema = yupString().oneOf(["tv", "widescreen", "desktop", "laptop", "tablet", "mobile"]).defined(); -export const AnalyticsHeatmapTokenResponseBodySchema = yupObject({ +export const AnalyticsClickmapTokenResponseBodySchema = yupObject({ token: yupString().defined(), origin: yupString().defined(), expires_at_millis: yupNumber().integer().defined(), }).defined(); -export const AnalyticsHeatmapCellSchema = yupObject({ +export const AnalyticsClickmapCellSchema = yupObject({ weekday: yupNumber().integer().min(1).max(7).defined(), hour: yupNumber().integer().min(0).max(23).defined(), value: yupNumber().integer().defined(), }).defined(); -export const AnalyticsHeatmapResponseBodySchema = yupObject({ - kind: AnalyticsHeatmapKindSchema, - cells: yupArray(AnalyticsHeatmapCellSchema).defined(), +export const AnalyticsClickmapResponseBodySchema = yupObject({ + kind: AnalyticsClickmapKindSchema, + cells: yupArray(AnalyticsClickmapCellSchema).defined(), // Fraction of source rows the result was computed from (1 = full scan). // Returned counts are pre-scaled by 1/sampling. sampling: yupNumber().min(0).max(1).optional().default(1), @@ -304,24 +304,24 @@ export type MetricsRecentUser = yup.InferType; export type MetricsResponse = yup.InferType; export type MetricsUserCounts = yup.InferType; export type UserActivityResponse = yup.InferType; -export type AnalyticsHeatmapKind = yup.InferType; -export type AnalyticsHeatmapDevice = yup.InferType; -export type AnalyticsHeatmapCell = yup.InferType; -export type AnalyticsHeatmapResponse = yup.InferType; -export type AnalyticsHeatmapTokenResponse = yup.InferType; +export type AnalyticsClickmapKind = yup.InferType; +export type AnalyticsClickmapDevice = yup.InferType; +export type AnalyticsClickmapCell = yup.InferType; +export type AnalyticsClickmapResponse = yup.InferType; +export type AnalyticsClickmapTokenResponse = yup.InferType; -// Single (camelCase) options shape for the heatmap SDK surface — shared by the +// Single (camelCase) options shape for the clickmap SDK surface — shared by the // StackAdminApp interface and its implementation so the two can't drift. The // HexclaveAdminInterface transport layer maps these to the snake_case request body. -export type AnalyticsHeatmapOptions = { - kind: AnalyticsHeatmapKind, +export type AnalyticsClickmapOptions = { + kind: AnalyticsClickmapKind, memberUserIds?: string[], routePath?: string, routeRegex?: string, urlPattern?: string, userId?: string, replayId?: string, - device?: AnalyticsHeatmapDevice, + device?: AnalyticsClickmapDevice, viewportWidthMin?: number, viewportWidthMax?: number, sampling?: number, diff --git a/packages/stack-shared/src/utils/analytics-clickmap-overlay.tsx b/packages/stack-shared/src/utils/analytics-clickmap-overlay.tsx new file mode 100644 index 0000000000..d1afca7df6 --- /dev/null +++ b/packages/stack-shared/src/utils/analytics-clickmap-overlay.tsx @@ -0,0 +1,15 @@ +/** + * Wire protocol for handing a clickmap overlay token from the dashboard to the + * in-page dev tool via `sessionStorage` + a window event. + * + * The token is a self-describing JWT: its payload already carries the + * `project_id` and `origin` it was minted for, so the reader derives both from + * the token itself and the writer only has to hand over a single value. The + * dashboard (writer) and the dev tool (reader) live in different packages but + * must agree on these exact names — this module is the single source of truth so + * they can never silently desync. + */ + +export const CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY = "hexclave-clickmap-token"; +export const CLICKMAP_OVERLAY_RESUME_STORAGE_KEY = "hexclave-clickmap-resume"; +export const CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT = "hexclave:clickmap-token-updated"; diff --git a/packages/stack-shared/src/utils/analytics-heatmap-overlay.tsx b/packages/stack-shared/src/utils/analytics-heatmap-overlay.tsx deleted file mode 100644 index 799601c37d..0000000000 --- a/packages/stack-shared/src/utils/analytics-heatmap-overlay.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Wire protocol for handing a heatmap overlay token from the dashboard to the - * in-page dev tool via `sessionStorage` + a window event. - * - * The dashboard (writer) and the dev tool (reader) live in different packages - * but must agree on these exact key names and event name — this module is the - * single source of truth so they can never silently desync. The reader's - * legacy-fallback logic and the writer's snippet stay in their respective - * packages; only the shared names + key builders live here. - */ - -export const HEATMAP_OVERLAY_TOKEN_STORAGE_KEY = "hexclave-heatmap-overlay-token"; -export const HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY = "hexclave-heatmap-overlay-origin"; -export const HEATMAP_OVERLAY_PROJECT_STORAGE_KEY = "hexclave-heatmap-overlay-project-id"; -export const HEATMAP_OVERLAY_RESUME_STORAGE_KEY = "hexclave-heatmap-overlay-resume"; -export const HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT = "hexclave:heatmap-token-updated"; - -/** Per-project sessionStorage key holding the overlay token. */ -export function getProjectHeatmapTokenStorageKey(projectId: string): string { - return `${HEATMAP_OVERLAY_TOKEN_STORAGE_KEY}:${projectId}`; -} - -/** Per-project sessionStorage key holding the origin the token was minted for. */ -export function getProjectHeatmapOriginStorageKey(projectId: string): string { - return `${HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY}:${projectId}`; -} diff --git a/packages/stack-shared/src/utils/dev-tool.tsx b/packages/stack-shared/src/utils/dev-tool.tsx index efa7dd1447..3af8b747ec 100644 --- a/packages/stack-shared/src/utils/dev-tool.tsx +++ b/packages/stack-shared/src/utils/dev-tool.tsx @@ -1,5 +1,5 @@ /** - * Shared identity of the Hexclave in-page dev tool / heatmap overlay. + * Shared identity of the Hexclave in-page dev tool / clickmap overlay. * * These constants are the single source of truth for "is this DOM / event / * stored click part of the dev tool itself?". They are consumed across package @@ -8,7 +8,7 @@ * prefixes every generated class with {@link DEV_TOOL_CLASS_PREFIX}; * - the event tracker uses them to skip self-clicks at ingest; * - the backend clickmap query uses them to filter dev-tool clicks out of - * aggregate heatmaps server-side. + * aggregate clickmaps server-side. * * Keep them here so a rename can never silently desync the SQL filter from the * actual DOM identity. diff --git a/packages/stack-shared/src/utils/elements-chain.tsx b/packages/stack-shared/src/utils/elements-chain.tsx index 6acd7231df..969f2400b7 100644 --- a/packages/stack-shared/src/utils/elements-chain.tsx +++ b/packages/stack-shared/src/utils/elements-chain.tsx @@ -1,7 +1,7 @@ /** * PostHog-style `elements_chain` format — the single owner of both halves of the * contract. The event tracker {@link buildElementsChain serializes} a clicked - * element (and its ancestors) into a string; the heatmap overlay + * element (and its ancestors) into a string; the clickmap overlay * {@link parseElementsChain parses} that string back into structured segments so * it can re-locate the element in a live DOM. * diff --git a/packages/template/src/dev-tool/dev-tool-core.ts b/packages/template/src/dev-tool/dev-tool-core.ts index 24f7e9f56a..b27eb9d797 100644 --- a/packages/template/src/dev-tool/dev-tool-core.ts +++ b/packages/template/src/dev-tool/dev-tool-core.ts @@ -2,17 +2,13 @@ import type { RequestLogEntry } from "@stackframe/stack-shared/dist/interface/client-interface"; import { - getProjectHeatmapOriginStorageKey, - getProjectHeatmapTokenStorageKey, - HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY, - HEATMAP_OVERLAY_PROJECT_STORAGE_KEY, - HEATMAP_OVERLAY_RESUME_STORAGE_KEY, - HEATMAP_OVERLAY_TOKEN_STORAGE_KEY, - HEATMAP_OVERLAY_TOKEN_UPDATED_EVENT, -} from "@stackframe/stack-shared/dist/utils/analytics-heatmap-overlay"; + CLICKMAP_OVERLAY_RESUME_STORAGE_KEY, + CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY, + CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, +} from "@stackframe/stack-shared/dist/utils/analytics-clickmap-overlay"; import { DEV_TOOL_ROOT_ID } from "@stackframe/stack-shared/dist/utils/dev-tool"; import { cssEscapeIdent } from "@stackframe/stack-shared/dist/utils/dom"; -import { AnalyticsHeatmapResponseBodySchema, type AnalyticsHeatmapResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { AnalyticsClickmapResponseBodySchema, type AnalyticsClickmapResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; import { parseElementsChain, type ElementsChainSegment } from "@stackframe/stack-shared/dist/utils/elements-chain"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; @@ -31,7 +27,7 @@ import { clampTriggerPosition, getSnappedTriggerPlacement, resolveTriggerPositio // Types // --------------------------------------------------------------------------- -type TabId = 'overview' | 'heatmaps' | 'customize' | 'ai' | 'dashboard' | 'console' | 'support'; +type TabId = 'overview' | 'clickmaps' | 'customize' | 'ai' | 'dashboard' | 'console' | 'support'; type TabResult = { element: HTMLElement, cleanup?: () => void }; @@ -75,12 +71,12 @@ const DOCS_URL = 'https://docs.hexclave.com'; const TABS: { id: TabId; label: string; icon: string }[] = [ { id: 'overview', label: 'Overview', icon: '' }, - { id: 'heatmaps', label: 'Clickmaps', icon: '' }, { id: 'customize', label: 'Customize', icon: '' }, { id: 'ai', label: 'AI', icon: '' }, { id: 'console', label: 'Console', icon: '' }, { id: 'dashboard', label: 'Dashboard', icon: '' }, { id: 'support', label: 'Support', icon: '' }, + { id: 'clickmaps', label: 'Clickmaps', icon: '' }, ]; const DEFAULT_STATE: DevToolState = { @@ -1795,7 +1791,7 @@ function createAITab(app: StackClientApp): HTMLElement { } // --------------------------------------------------------------------------- -// Heatmaps tab +// Clickmaps tab // --------------------------------------------------------------------------- type DevToolClickGroup = { @@ -1806,50 +1802,56 @@ type DevToolClickGroup = { rect: DOMRect | null; }; -type HeatmapGroupOverlayElement = { +type ClickmapGroupOverlayElement = { marker: HTMLElement; outline: HTMLElement; }; -const HEATMAP_FILTERS_STORAGE_KEY = 'hexclave-heatmap-overlay-filters'; +const CLICKMAP_FILTERS_STORAGE_KEY = 'hexclave-clickmap-overlay-filters'; -type HeatmapRangeKey = '24h' | '7d' | '30d'; -type HeatmapDeviceKey = 'all' | 'mobile' | 'tablet' | 'laptop' | 'desktop' | 'widescreen' | 'tv'; +type ClickmapRangeKey = '24h' | '7d' | '30d'; +type ClickmapDeviceKey = 'all' | 'mobile' | 'tablet' | 'laptop' | 'desktop' | 'widescreen' | 'tv'; +type ClickmapUrlPatternMode = 'glob' | 'regex'; -type HeatmapFilters = { - range: HeatmapRangeKey, - device: HeatmapDeviceKey, +type ClickmapFilters = { + range: ClickmapRangeKey, + device: ClickmapDeviceKey, urlPattern: string, + urlPatternMode: ClickmapUrlPatternMode, elementSearch: string, }; -const HEATMAP_DEFAULT_FILTERS: HeatmapFilters = { +const CLICKMAP_DEFAULT_FILTERS: ClickmapFilters = { range: '7d', device: 'all', urlPattern: '', + urlPatternMode: 'glob', elementSearch: '', }; -const HEATMAP_RANGE_MS: Record = { +const CLICKMAP_RANGE_MS: Record = { '24h': 24 * 60 * 60 * 1000, '7d': 7 * 24 * 60 * 60 * 1000, '30d': 30 * 24 * 60 * 60 * 1000, }; -function isHeatmapRangeKey(value: unknown): value is HeatmapRangeKey { +function isClickmapRangeKey(value: unknown): value is ClickmapRangeKey { return value === '24h' || value === '7d' || value === '30d'; } -function isHeatmapDeviceKey(value: unknown): value is HeatmapDeviceKey { +function isClickmapDeviceKey(value: unknown): value is ClickmapDeviceKey { return value === 'all' || value === 'mobile' || value === 'tablet' || value === 'laptop' || value === 'desktop' || value === 'widescreen' || value === 'tv'; } -const HEATMAP_DOM_INDEX_DEBOUNCE_MS = 250; +function isClickmapUrlPatternMode(value: unknown): value is ClickmapUrlPatternMode { + return value === 'glob' || value === 'regex'; +} +const CLICKMAP_DOM_INDEX_DEBOUNCE_MS = 250; -type DevToolServerHeatmapSelector = { +type DevToolServerClickmapSelector = { selector: string; clicks: number; }; -type DevToolServerHeatmapElement = { +type DevToolServerClickmapElement = { elementsChain: string; elementsText: string; tagName: string; @@ -1857,15 +1859,15 @@ type DevToolServerHeatmapElement = { clicks: number; }; -type DevToolServerHeatmap = { +type DevToolServerClickmap = { path: string; // True aggregate click total returned for the active filter (summed across // every matching route), independent of how many elements can be drawn on the // current page's DOM. The overlay can only render elements that exist on the // page you're viewing, but this count reflects the full pattern. totalClicks: number; - selectors: DevToolServerHeatmapSelector[]; - elements: DevToolServerHeatmapElement[]; + selectors: DevToolServerClickmapSelector[]; + elements: DevToolServerClickmapElement[]; }; function cssEscapeAttrValue(value: string): string { @@ -1878,12 +1880,12 @@ function readChainAttr(segment: ElementsChainSegment, attr: string): string { return typeof value === 'string' ? value : ''; } -function formatHeatmapCount(value: number): string { +function formatClickmapCount(value: number): string { if (value >= 1000) return `${Math.round(value / 100) / 10}k`; return String(value); } -function getHeatmapHue(count: number, maxCount: number): number { +function getClickmapHue(count: number, maxCount: number): number { if (maxCount <= 1) return 185; const intensity = Math.min(1, count / maxCount); return 185 - Math.round(intensity * 155); @@ -1906,7 +1908,7 @@ function getReadableElementLabel(element: Element): string { return element.tagName.toLowerCase(); } -function isElementVisibleForHeatmap(element: Element): boolean { +function isElementVisibleForClickmap(element: Element): boolean { if (element.closest(`#${ROOT_ID}`) != null) { return false; } @@ -1927,7 +1929,7 @@ function isElementVisibleForHeatmap(element: Element): boolean { function getElementFromSelector(selector: string): Element | null { try { const elements = Array.from(document.querySelectorAll(selector)); - return elements.find(isElementVisibleForHeatmap) ?? null; + return elements.find(isElementVisibleForClickmap) ?? null; } catch { return null; } @@ -1942,10 +1944,6 @@ function getSessionStorageString(key: string): string | null { } } -function getActiveHeatmapProjectId(fallbackProjectId: string): string { - return getSessionStorageString(HEATMAP_OVERLAY_PROJECT_STORAGE_KEY) ?? fallbackProjectId; -} - function removeSessionStorageItem(key: string): void { try { sessionStorage.removeItem(key); @@ -1955,7 +1953,12 @@ function removeSessionStorageItem(key: string): void { } } -function getJwtPayloadProjectId(token: string): string | null { +// Read a string claim out of a JWT payload without verifying the signature. The +// clickmap token is self-describing — it carries the `project_id` and `origin` +// it was minted for — so the overlay derives both from the token itself instead +// of needing them handed over alongside. The server still verifies the token on +// every request; this is only used to scope/label the token client-side. +function getJwtPayloadClaim(token: string, claim: string): string | null { const tokenParts = token.split('.'); if (tokenParts.length < 2 || tokenParts[1] === '') { return null; @@ -1968,52 +1971,41 @@ function getJwtPayloadProjectId(token: string): string | null { if (typeof payload !== 'object' || payload === null) { return null; } - const projectId = Reflect.get(payload, 'project_id'); - return typeof projectId === 'string' ? projectId : null; + const value = Reflect.get(payload, claim); + return typeof value === 'string' ? value : null; } catch { return null; } } -function getHeatmapTokenFromStorage(projectId: string): string | null { - const activeProjectId = getActiveHeatmapProjectId(projectId); - const projectToken = getSessionStorageString(getProjectHeatmapTokenStorageKey(activeProjectId)); - if (projectToken != null) { - return projectToken; - } - const legacyToken = getSessionStorageString(HEATMAP_OVERLAY_TOKEN_STORAGE_KEY); - if (legacyToken == null) { +function getClickmapTokenFromStorage(projectId: string): string | null { + const token = getSessionStorageString(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY); + if (token == null) { return null; } - const legacyProjectId = getJwtPayloadProjectId(legacyToken); - return legacyProjectId == null || legacyProjectId === activeProjectId ? legacyToken : null; + // A token minted for a different project must not apply to this app. + const tokenProjectId = getJwtPayloadClaim(token, 'project_id'); + return tokenProjectId == null || tokenProjectId === projectId ? token : null; } -function getHeatmapOriginFromStorage(projectId: string): string | null { - const activeProjectId = getActiveHeatmapProjectId(projectId); - return getSessionStorageString(getProjectHeatmapOriginStorageKey(activeProjectId)) ?? getSessionStorageString(HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY); +function getClickmapOriginFromStorage(projectId: string): string | null { + const token = getClickmapTokenFromStorage(projectId); + return token == null ? null : getJwtPayloadClaim(token, 'origin'); } -function clearHeatmapTokenStorage(projectId: string): void { - const activeProjectId = getActiveHeatmapProjectId(projectId); - removeSessionStorageItem(getProjectHeatmapTokenStorageKey(activeProjectId)); - removeSessionStorageItem(getProjectHeatmapOriginStorageKey(activeProjectId)); - removeSessionStorageItem(HEATMAP_OVERLAY_PROJECT_STORAGE_KEY); - const legacyToken = getSessionStorageString(HEATMAP_OVERLAY_TOKEN_STORAGE_KEY); - const legacyProjectId = legacyToken == null ? null : getJwtPayloadProjectId(legacyToken); - if (legacyProjectId == null || legacyProjectId === activeProjectId) { - removeSessionStorageItem(HEATMAP_OVERLAY_TOKEN_STORAGE_KEY); - removeSessionStorageItem(HEATMAP_OVERLAY_ORIGIN_STORAGE_KEY); +function clearClickmapTokenStorage(projectId: string): void { + if (getClickmapTokenFromStorage(projectId) != null) { + removeSessionStorageItem(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY); } } -function parseServerHeatmapResponse(value: unknown, path: string): DevToolServerHeatmap { - let parsed: AnalyticsHeatmapResponse; +function parseServerClickmapResponse(value: unknown, path: string): DevToolServerClickmap { + let parsed: AnalyticsClickmapResponse; try { // Validate against the canonical response contract instead of hand-walking // `unknown`. Anything that doesn't match is treated as "no data" so the // overlay stays alive rather than crashing on shape drift. - parsed = AnalyticsHeatmapResponseBodySchema.validateSync(value); + parsed = AnalyticsClickmapResponseBodySchema.validateSync(value); } catch { return { path, totalClicks: 0, selectors: [], elements: [] }; } @@ -2035,7 +2027,7 @@ function parseServerHeatmapResponse(value: unknown, path: string): DevToolServer // Heuristic: does this path segment look like an opaque per-entity id (a UUID, // numeric id, Mongo ObjectId, ULID, etc.) rather than a human-readable slug? -// Used to auto-wildcard slug routes so a single heatmap pattern aggregates +// Used to auto-wildcard slug routes so a single clickmap pattern aggregates // across every user/team instead of just the one currently in the URL. function isDynamicPathSegment(segment: string): boolean { if (segment === '') return false; @@ -2053,7 +2045,7 @@ function isDynamicPathSegment(segment: string): boolean { return false; } -// Turn the current pathname into a heatmap URL pattern by replacing id-like +// Turn the current pathname into a clickmap URL pattern by replacing id-like // segments with `*` (PostHog-style wildcards). Stable slugs are preserved so // e.g. `/teams//settings` becomes `/teams/*/settings`. function wildcardizePathname(pathname: string): string { @@ -2063,23 +2055,41 @@ function wildcardizePathname(pathname: string): string { return trailingSlash ? `${joined}/` : joined; } -// Does `path` match a PostHog-style URL pattern (where `*` is a wildcard)? -// Used to tell the user when the page they're on isn't covered by the pattern, -// so the overlay can't be drawn here even though aggregate data exists. -function patternMatchesPath(pattern: string, path: string): boolean { - if (pattern === '') return true; - const regexSource = pattern +// Translate a PostHog-style glob (where `*` is the only wildcard) into an +// anchored regex source mirroring the backend's SQL LIKE semantics. +function globToRegexSource(glob: string): string { + return glob .split('*') .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) .join('.*'); +} + +function isValidRegexSource(source: string): boolean { + try { + new RegExp(source); + return true; + } catch { + return false; + } +} + +// Does `path` match the active URL pattern? Used to tell the user when the page +// they're on isn't covered by the pattern, so the overlay can't be drawn here +// even though aggregate data exists. Glob mode mirrors the backend's anchored +// `LIKE`; regex mode mirrors ClickHouse `match()` (unanchored RE2). +function patternMatchesPath(pattern: string, path: string, mode: ClickmapUrlPatternMode): boolean { + if (pattern === '') return true; try { - return new RegExp(`^${regexSource}$`).test(path); + if (mode === 'regex') { + return new RegExp(pattern).test(path); + } + return new RegExp(`^${globToRegexSource(pattern)}$`).test(path); } catch { return false; } } -function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabResult { +function createClickmapsTab(app: StackClientApp, onBack: () => void): TabResult { const container = h('div', { className: 'sdt-hm' }); const overlayRoot = h('div', { className: 'sdt-hm-overlay-root', 'aria-hidden': 'true' }); const statsCount = h('div', { className: 'sdt-hm-stat-value' }, '0'); @@ -2091,43 +2101,45 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe const overlayToggle = h('button', { className: 'sdt-hm-btn sdt-hm-btn-primary' }, 'Hide'); const expandButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Expand clickmap options', title: 'Expand clickmap options' }); const backButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Back', title: 'Back' }); - const miniClicks = h('span', { className: 'sdt-hm-toolbar-metric' }, '0 clicks'); + const miniClicks = h('span', { className: 'sdt-hm-toolbar-metric-value' }, '0'); + const miniElements = h('span', { className: 'sdt-hm-toolbar-metric-value' }, '0'); - function readStoredFilters(): HeatmapFilters { + function readStoredFilters(): ClickmapFilters { try { - const raw = sessionStorage.getItem(HEATMAP_FILTERS_STORAGE_KEY); - if (raw == null) return { ...HEATMAP_DEFAULT_FILTERS }; + const raw = sessionStorage.getItem(CLICKMAP_FILTERS_STORAGE_KEY); + if (raw == null) return { ...CLICKMAP_DEFAULT_FILTERS }; const parsed: unknown = JSON.parse(raw); - if (parsed == null || typeof parsed !== 'object') return { ...HEATMAP_DEFAULT_FILTERS }; + if (parsed == null || typeof parsed !== 'object') return { ...CLICKMAP_DEFAULT_FILTERS }; const obj = parsed as Record; return { - range: isHeatmapRangeKey(obj.range) ? obj.range : HEATMAP_DEFAULT_FILTERS.range, - device: isHeatmapDeviceKey(obj.device) ? obj.device : HEATMAP_DEFAULT_FILTERS.device, - urlPattern: typeof obj.urlPattern === 'string' ? obj.urlPattern : HEATMAP_DEFAULT_FILTERS.urlPattern, - elementSearch: typeof obj.elementSearch === 'string' ? obj.elementSearch : HEATMAP_DEFAULT_FILTERS.elementSearch, + range: isClickmapRangeKey(obj.range) ? obj.range : CLICKMAP_DEFAULT_FILTERS.range, + device: isClickmapDeviceKey(obj.device) ? obj.device : CLICKMAP_DEFAULT_FILTERS.device, + urlPattern: typeof obj.urlPattern === 'string' ? obj.urlPattern : CLICKMAP_DEFAULT_FILTERS.urlPattern, + urlPatternMode: isClickmapUrlPatternMode(obj.urlPatternMode) ? obj.urlPatternMode : CLICKMAP_DEFAULT_FILTERS.urlPatternMode, + elementSearch: typeof obj.elementSearch === 'string' ? obj.elementSearch : CLICKMAP_DEFAULT_FILTERS.elementSearch, }; } catch { - return { ...HEATMAP_DEFAULT_FILTERS }; + return { ...CLICKMAP_DEFAULT_FILTERS }; } } - function persistFilters(next: HeatmapFilters) { + function persistFilters(next: ClickmapFilters) { try { - sessionStorage.setItem(HEATMAP_FILTERS_STORAGE_KEY, JSON.stringify(next)); + sessionStorage.setItem(CLICKMAP_FILTERS_STORAGE_KEY, JSON.stringify(next)); } catch { // ignore storage errors } } let currentPath = window.location.pathname; - let serverHeatmap: DevToolServerHeatmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; - let loadingServerHeatmap = false; - let serverHeatmapError: string | null = null; - let serverHeatmapRequestId = 0; + let serverClickmap: DevToolServerClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; + let loadingServerClickmap = false; + let serverClickmapError: string | null = null; + let serverClickmapRequestId = 0; let overlayVisible = true; let expanded = false; let renderFrame = 0; let overlayMode: 'hidden' | 'elements' = 'hidden'; - const groupOverlayElements = new Map(); + const groupOverlayElements = new Map(); // DOM-index cache for fast element-chain inference. const domIndex = new Map(); @@ -2137,7 +2149,7 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe domIndex.clear(); const all = document.querySelectorAll('*'); for (const el of all) { - if (!isElementVisibleForHeatmap(el)) continue; + if (!isElementVisibleForClickmap(el)) continue; const tag = el.tagName.toLowerCase(); const bucket = domIndex.get(tag) ?? []; bucket.push(el); @@ -2159,11 +2171,11 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe domIndexDebounce = 0; invalidateDomIndex(); scheduleRender(); - }, HEATMAP_DOM_INDEX_DEBOUNCE_MS); + }, CLICKMAP_DOM_INDEX_DEBOUNCE_MS); } function isElementChainCandidateUnique(matches: Element[]): Element | null { - const visible = matches.filter(isElementVisibleForHeatmap); + const visible = matches.filter(isElementVisibleForClickmap); return visible.length === 1 ? visible[0] : null; } @@ -2297,39 +2309,35 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe setHtml(backButton, ''); const chevronUpSvg = ''; const chevronDownSvg = ''; + const clicksIconSvg = ''; + const elementsIconSvg = ''; setHtml(expandButton, chevronUpSvg); - const toolbar = h('div', { className: 'sdt-hm-toolbar' }, - backButton, - h('div', { className: 'sdt-hm-toolbar-main' }, - h('div', { className: 'sdt-hm-toolbar-title' }, 'Clickmap'), - h('div', { className: 'sdt-hm-toolbar-subtitle' }, 'Aggregated clicks for this page'), - ), - miniClicks, - expandButton, - ); const stats = h('div', { className: 'sdt-hm-stats' }, h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Clicks'), statsCount), h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Elements'), selectorCount), h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Viewport'), viewportValue), ); - let filters: HeatmapFilters = readStoredFilters(); + let filters: ClickmapFilters = readStoredFilters(); let filterReloadDebounce = 0; // When the user hasn't typed a custom pattern, the URL pattern field mirrors // the current route with id-like segments auto-wildcarded (`/teams/*/settings`) - // so the heatmap aggregates across all entities. A stored non-empty pattern + // so the clickmap aggregates across all entities. A stored non-empty pattern // means the user took manual control, so we leave it alone. let urlPatternUserEdited = filters.urlPattern.trim() !== ''; function getEffectiveUrlPattern(): string { + // Auto route-tracking is a glob concept; in regex mode an empty field means + // "this exact page" (the route_path fallback), never an auto-wildcard. + if (filters.urlPatternMode === 'regex') return filters.urlPattern.trim(); if (urlPatternUserEdited) return filters.urlPattern.trim(); return wildcardizePathname(window.location.pathname); } - // Reflect the current route into the field while in auto mode. No-op once the - // user has typed their own pattern. + // Reflect the current route into the field while in glob auto mode. No-op in + // regex mode or once the user has typed their own pattern. function syncAutoUrlPattern() { - if (urlPatternUserEdited) return; + if (filters.urlPatternMode === 'regex' || urlPatternUserEdited) return; const auto = wildcardizePathname(window.location.pathname); if (urlPatternInput.value !== auto) { urlPatternInput.value = auto; @@ -2351,15 +2359,65 @@ function createHeatmapsTab(app: StackClientApp, onBack: () => void): TabRe ['7d', 'Last 7 days'], ['30d', 'Last 30 days'], ], filters.range); - const deviceSelect = makeFilterSelect([ - ['all', 'All viewports'], + // Viewport filter as a segmented switcher: equal-weight options with a single + // pill that slides to the active mode, instead of a hidden-until-opened native + // setCustomOrigin(event.target.value)} placeholder="http://localhost:3000" /> - @@ -470,6 +486,7 @@ export default function PageClient() { diff --git a/apps/dashboard/src/components/ui/copy-button.tsx b/apps/dashboard/src/components/ui/copy-button.tsx index d82e0ce699..bd1497b3b2 100644 --- a/apps/dashboard/src/components/ui/copy-button.tsx +++ b/apps/dashboard/src/components/ui/copy-button.tsx @@ -1,7 +1,7 @@ "use client"; import { cn } from "@/lib/utils"; -import { CopyIcon, SparkleIcon } from "@phosphor-icons/react"; +import { CheckIcon, CopyIcon, SparkleIcon } from "@phosphor-icons/react"; import { forwardRefIfNeeded } from "@stackframe/stack-shared/dist/utils/react"; import React from "react"; import { Button } from "./button"; @@ -9,9 +9,27 @@ import { useToast } from "./use-toast"; const CopyButton = forwardRefIfNeeded< React.ElementRef, - React.ComponentProps & { content: string } ->((props, ref) => { + React.ComponentProps & { content: string, initialCopied?: boolean } +>(({ content, initialCopied, ...props }, ref) => { const { toast } = useToast(); + const [copied, setCopied] = React.useState(false); + const resetTimeout = React.useRef | null>(null); + + const showCopied = React.useCallback(() => { + setCopied(true); + if (resetTimeout.current != null) clearTimeout(resetTimeout.current); + resetTimeout.current = setTimeout(() => setCopied(false), 2000); + }, []); + + React.useEffect(() => () => { + if (resetTimeout.current != null) clearTimeout(resetTimeout.current); + }, []); + + // Reflect a copy that already happened elsewhere (e.g. the snippet was + // auto-copied to the clipboard when this field was rendered). + React.useEffect(() => { + if (initialCopied) showCopied(); + }, [initialCopied, showCopied]); return ( ); }); diff --git a/apps/dashboard/src/components/ui/copy-field.tsx b/apps/dashboard/src/components/ui/copy-field.tsx index b9ad10fde5..a8945be55b 100644 --- a/apps/dashboard/src/components/ui/copy-field.tsx +++ b/apps/dashboard/src/components/ui/copy-field.tsx @@ -10,6 +10,7 @@ export function CopyField(props: { helper?: React.ReactNode, monospace?: boolean, fixedSize?: boolean, + initialCopied?: boolean, } & ({ type: "textarea", height?: number, @@ -36,7 +37,7 @@ export function CopyField(props: { resize: props.fixedSize ? "none" : "vertical" }} /> - + ) : (
@@ -47,7 +48,7 @@ export function CopyField(props: { fontFamily: props.monospace ? "ui-monospace, monospace" : "inherit", }} /> - +
)} diff --git a/packages/template/src/dev-tool/dev-tool-core.ts b/packages/template/src/dev-tool/dev-tool-core.ts index b27eb9d797..6641a2c159 100644 --- a/packages/template/src/dev-tool/dev-tool-core.ts +++ b/packages/template/src/dev-tool/dev-tool-core.ts @@ -27,7 +27,7 @@ import { clampTriggerPosition, getSnappedTriggerPlacement, resolveTriggerPositio // Types // --------------------------------------------------------------------------- -type TabId = 'overview' | 'clickmaps' | 'customize' | 'ai' | 'dashboard' | 'console' | 'support'; +type TabId = 'overview' | 'customize' | 'ai' | 'dashboard' | 'console' | 'support'; type TabResult = { element: HTMLElement, cleanup?: () => void }; @@ -76,9 +76,13 @@ const TABS: { id: TabId; label: string; icon: string }[] = [ { id: 'console', label: 'Console', icon: '' }, { id: 'dashboard', label: 'Dashboard', icon: '' }, { id: 'support', label: 'Support', icon: '' }, - { id: 'clickmaps', label: 'Clickmaps', icon: '' }, ]; +// Clickmaps is intentionally NOT a dev tool tab. It's a fully independent +// feature with its own mount (see createDevTool), opened via a dashboard-minted +// token (the CLICKMAP_OVERLAY_TOKEN_UPDATED event / resume flow), never from the +// dev tool. The two coexist without affecting each other. + const DEFAULT_STATE: DevToolState = { isOpen: false, activeTab: 'overview', @@ -1811,21 +1815,23 @@ const CLICKMAP_FILTERS_STORAGE_KEY = 'hexclave-clickmap-overlay-filters'; type ClickmapRangeKey = '24h' | '7d' | '30d'; type ClickmapDeviceKey = 'all' | 'mobile' | 'tablet' | 'laptop' | 'desktop' | 'widescreen' | 'tv'; -type ClickmapUrlPatternMode = 'glob' | 'regex'; type ClickmapFilters = { range: ClickmapRangeKey, device: ClickmapDeviceKey, urlPattern: string, - urlPatternMode: ClickmapUrlPatternMode, elementSearch: string, }; +type ClickmapViewportBucket = { + min: number, + max: number | null, +}; + const CLICKMAP_DEFAULT_FILTERS: ClickmapFilters = { range: '7d', device: 'all', urlPattern: '', - urlPatternMode: 'glob', elementSearch: '', }; @@ -1835,15 +1841,40 @@ const CLICKMAP_RANGE_MS: Record = { '30d': 30 * 24 * 60 * 60 * 1000, }; +const CLICKMAP_VIEWPORT_BUCKETS: Record, ClickmapViewportBucket> = { + mobile: { min: 0, max: 767 }, + tablet: { min: 768, max: 1023 }, + laptop: { min: 1024, max: 1199 }, + desktop: { min: 1200, max: 1439 }, + widescreen: { min: 1440, max: 1919 }, + tv: { min: 1920, max: null }, +}; + +function getClickmapViewportBucket(device: ClickmapDeviceKey): ClickmapViewportBucket | null { + if (device === 'all') return null; + return CLICKMAP_VIEWPORT_BUCKETS[device]; +} + +function isClickmapViewportWidthInBucket(width: number, bucket: ClickmapViewportBucket): boolean { + return width >= bucket.min && (bucket.max == null || width <= bucket.max); +} + +function getClickmapRecommendedViewportWidth(bucket: ClickmapViewportBucket): number { + if (bucket.max == null) return bucket.min; + return Math.round((bucket.min + bucket.max) / 2); +} + +function formatClickmapViewportBucket(bucket: ClickmapViewportBucket): string { + if (bucket.max == null) return `${bucket.min}px+`; + return `${bucket.min}-${bucket.max}px`; +} + function isClickmapRangeKey(value: unknown): value is ClickmapRangeKey { return value === '24h' || value === '7d' || value === '30d'; } function isClickmapDeviceKey(value: unknown): value is ClickmapDeviceKey { return value === 'all' || value === 'mobile' || value === 'tablet' || value === 'laptop' || value === 'desktop' || value === 'widescreen' || value === 'tv'; } -function isClickmapUrlPatternMode(value: unknown): value is ClickmapUrlPatternMode { - return value === 'glob' || value === 'regex'; -} const CLICKMAP_DOM_INDEX_DEBOUNCE_MS = 250; type DevToolServerClickmapSelector = { @@ -1978,25 +2009,17 @@ function getJwtPayloadClaim(token: string, claim: string): string | null { } } -function getClickmapTokenFromStorage(projectId: string): string | null { - const token = getSessionStorageString(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY); - if (token == null) { - return null; - } - // A token minted for a different project must not apply to this app. - const tokenProjectId = getJwtPayloadClaim(token, 'project_id'); - return tokenProjectId == null || tokenProjectId === projectId ? token : null; +function getClickmapTokenFromStorage(): string | null { + return getSessionStorageString(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY); } -function getClickmapOriginFromStorage(projectId: string): string | null { - const token = getClickmapTokenFromStorage(projectId); +function getClickmapOriginFromStorage(): string | null { + const token = getClickmapTokenFromStorage(); return token == null ? null : getJwtPayloadClaim(token, 'origin'); } -function clearClickmapTokenStorage(projectId: string): void { - if (getClickmapTokenFromStorage(projectId) != null) { - removeSessionStorageItem(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY); - } +function clearClickmapTokenStorage(): void { + removeSessionStorageItem(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY); } function parseServerClickmapResponse(value: unknown, path: string): DevToolServerClickmap { @@ -2064,32 +2087,20 @@ function globToRegexSource(glob: string): string { .join('.*'); } -function isValidRegexSource(source: string): boolean { - try { - new RegExp(source); - return true; - } catch { - return false; - } -} - // Does `path` match the active URL pattern? Used to tell the user when the page // they're on isn't covered by the pattern, so the overlay can't be drawn here -// even though aggregate data exists. Glob mode mirrors the backend's anchored -// `LIKE`; regex mode mirrors ClickHouse `match()` (unanchored RE2). -function patternMatchesPath(pattern: string, path: string, mode: ClickmapUrlPatternMode): boolean { +// even though aggregate data exists. Glob matching mirrors the backend's +// anchored `LIKE`. +function patternMatchesPath(pattern: string, path: string): boolean { if (pattern === '') return true; try { - if (mode === 'regex') { - return new RegExp(pattern).test(path); - } return new RegExp(`^${globToRegexSource(pattern)}$`).test(path); } catch { return false; } } -function createClickmapsTab(app: StackClientApp, onBack: () => void): TabResult { +function createClickmapsTab(app: StackClientApp, onClose: () => void): TabResult { const container = h('div', { className: 'sdt-hm' }); const overlayRoot = h('div', { className: 'sdt-hm-overlay-root', 'aria-hidden': 'true' }); const statsCount = h('div', { className: 'sdt-hm-stat-value' }, '0'); @@ -2098,9 +2109,31 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR const list = h('div', { className: 'sdt-hm-list' }); const empty = h('div', { className: 'sdt-hm-empty' }, 'Paste a clickmap token from the dashboard to load aggregated element clicks for this page.'); const status = h('div', { className: 'sdt-hm-token-status' }); + const viewportWarningTitle = h('div', { className: 'sdt-hm-viewport-warning-title' }); + const viewportWarningBody = h('div', { className: 'sdt-hm-viewport-warning-body' }); + const viewportWarningWidthValue = h('code', { className: 'sdt-hm-viewport-warning-code' }); + const viewportWarningHeightValue = h('code', { className: 'sdt-hm-viewport-warning-code' }); + const viewportWarningWidthCopy = h('button', { className: 'sdt-hm-copy-btn', type: 'button' }); + const viewportWarningHeightCopy = h('button', { className: 'sdt-hm-copy-btn', type: 'button' }); + const viewportWarning = h('div', { className: 'sdt-hm-viewport-warning', role: 'status' }, + viewportWarningTitle, + viewportWarningBody, + h('div', { className: 'sdt-hm-viewport-warning-actions' }, + h('span', { className: 'sdt-hm-viewport-warning-action' }, + h('span', { className: 'sdt-hm-viewport-warning-label' }, 'Width'), + viewportWarningWidthValue, + viewportWarningWidthCopy, + ), + h('span', { className: 'sdt-hm-viewport-warning-action' }, + h('span', { className: 'sdt-hm-viewport-warning-label' }, 'Height'), + viewportWarningHeightValue, + viewportWarningHeightCopy, + ), + ), + ); const overlayToggle = h('button', { className: 'sdt-hm-btn sdt-hm-btn-primary' }, 'Hide'); const expandButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Expand clickmap options', title: 'Expand clickmap options' }); - const backButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Back', title: 'Back' }); + const closeButton = h('button', { className: 'sdt-hm-icon-btn', 'aria-label': 'Close clickmap', title: 'Close clickmap' }); const miniClicks = h('span', { className: 'sdt-hm-toolbar-metric-value' }, '0'); const miniElements = h('span', { className: 'sdt-hm-toolbar-metric-value' }, '0'); @@ -2115,7 +2148,6 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR range: isClickmapRangeKey(obj.range) ? obj.range : CLICKMAP_DEFAULT_FILTERS.range, device: isClickmapDeviceKey(obj.device) ? obj.device : CLICKMAP_DEFAULT_FILTERS.device, urlPattern: typeof obj.urlPattern === 'string' ? obj.urlPattern : CLICKMAP_DEFAULT_FILTERS.urlPattern, - urlPatternMode: isClickmapUrlPatternMode(obj.urlPatternMode) ? obj.urlPatternMode : CLICKMAP_DEFAULT_FILTERS.urlPatternMode, elementSearch: typeof obj.elementSearch === 'string' ? obj.elementSearch : CLICKMAP_DEFAULT_FILTERS.elementSearch, }; } catch { @@ -2141,6 +2173,23 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR let overlayMode: 'hidden' | 'elements' = 'hidden'; const groupOverlayElements = new Map(); + function resetCopyButton(button: HTMLElement, label: string) { + button.textContent = label; + } + + function copyClickmapViewportValue(button: HTMLElement, value: string, label: string) { + runAsynchronously(async () => { + try { + await navigator.clipboard.writeText(value); + button.textContent = 'Copied'; + window.setTimeout(() => resetCopyButton(button, label), 1200); + } catch { + button.textContent = 'Copy failed'; + window.setTimeout(() => resetCopyButton(button, label), 1600); + } + }); + } + // DOM-index cache for fast element-chain inference. const domIndex = new Map(); let domIndexDirty = true; @@ -2306,12 +2355,20 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR return null; } - setHtml(backButton, ''); + setHtml(closeButton, ''); const chevronUpSvg = ''; const chevronDownSvg = ''; const clicksIconSvg = ''; const elementsIconSvg = ''; setHtml(expandButton, chevronUpSvg); + resetCopyButton(viewportWarningWidthCopy, 'Copy width'); + resetCopyButton(viewportWarningHeightCopy, 'Copy height'); + viewportWarningWidthCopy.addEventListener('click', () => { + copyClickmapViewportValue(viewportWarningWidthCopy, viewportWarningWidthValue.textContent, 'Copy width'); + }); + viewportWarningHeightCopy.addEventListener('click', () => { + copyClickmapViewportValue(viewportWarningHeightCopy, viewportWarningHeightValue.textContent, 'Copy height'); + }); const stats = h('div', { className: 'sdt-hm-stats' }, h('div', { className: 'sdt-hm-stat' }, h('div', { className: 'sdt-hm-stat-label' }, 'Clicks'), statsCount), @@ -2328,16 +2385,13 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR let urlPatternUserEdited = filters.urlPattern.trim() !== ''; function getEffectiveUrlPattern(): string { - // Auto route-tracking is a glob concept; in regex mode an empty field means - // "this exact page" (the route_path fallback), never an auto-wildcard. - if (filters.urlPatternMode === 'regex') return filters.urlPattern.trim(); if (urlPatternUserEdited) return filters.urlPattern.trim(); return wildcardizePathname(window.location.pathname); } - // Reflect the current route into the field while in glob auto mode. No-op in - // regex mode or once the user has typed their own pattern. + // Reflect the current route into the field while in auto mode. No-op once the + // user has typed their own pattern. function syncAutoUrlPattern() { - if (filters.urlPatternMode === 'regex' || urlPatternUserEdited) return; + if (urlPatternUserEdited) return; const auto = wildcardizePathname(window.location.pathname); if (urlPatternInput.value !== auto) { urlPatternInput.value = auto; @@ -2428,12 +2482,14 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR }) as HTMLInputElement; urlPatternInput.value = getEffectiveUrlPattern(); // Shown only while the active pattern doesn't cover the current page (see - // render); resets the field back to the auto-wildcarded current route. + // render); reverts the field back to the auto-wildcarded current route. const urlPatternReset = h('button', { className: 'sdt-hm-filter-reset', type: 'button', - title: 'Reset the URL pattern to the current page', - }, 'Reset') as HTMLButtonElement; + 'aria-label': 'Revert the URL pattern to the current page', + title: 'Revert the URL pattern to the current page', + }) as HTMLButtonElement; + setHtml(urlPatternReset, ''); // Info button + popover explaining the URL pattern syntax. The backend // translates `*` into a SQL LIKE `%`, so `*` is the only wildcard — every @@ -2459,37 +2515,14 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR const urlHelpTitle = h('div', { className: 'sdt-hm-url-help-title' }); const urlHelpBody = h('div', { className: 'sdt-hm-url-help-body' }); const urlHelpRows = h('div', { className: 'sdt-hm-url-help-rows' }); - // Cheatsheet content tracks the active matching mode so it only ever shows - // syntax that actually works. function renderUrlHelp() { - if (filters.urlPatternMode === 'regex') { - urlHelpTitle.textContent = 'URL pattern · regex'; - urlHelpBody.replaceChildren( - 'Matched against the pathname with ClickHouse ', - makeCode('match()'), - ' (RE2 syntax), unanchored — add ', - makeCode('^'), - ' / ', - makeCode('$'), - ' to pin the start and end. No domain, hash, or query string.', - ); - urlHelpRows.replaceChildren( - makeUrlHelpRow('^/pricing$', 'Exactly /pricing'), - makeUrlHelpRow('^/products/', 'Anything starting with /products/'), - makeUrlHelpRow('/(privacy|terms)$', 'Ends in /privacy or /terms'), - makeUrlHelpRow('^/teams/[^/]+/members$', 'One id segment in the middle'), - makeUrlHelpRow('\\.html$', 'Paths ending in .html'), - makeUrlHelpRow('(empty)', 'This exact page only'), - ); - return; - } urlHelpTitle.textContent = 'URL pattern · glob'; urlHelpBody.replaceChildren( 'Limits the clickmap to pages whose path matches. Matched against the pathname only — no domain, hash, or query string. ', makeCode('*'), ' is the only wildcard and stands in for any characters (including ', makeCode('/'), - '). Everything else is matched literally — switch to regex for full RE2 syntax.', + '). Everything else is matched literally.', ); urlHelpRows.replaceChildren( makeUrlHelpRow('/pricing', 'That exact page'), @@ -2519,58 +2552,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR urlPatternHelp.addEventListener('click', (event) => { event.stopPropagation(); }); - - // Glob vs. regex matching. The backend already supports both (`url_pattern` - // → SQL LIKE, `route_regex` → ClickHouse match()); this toggle picks which - // one we send and keeps the local coverage check in sync. - const urlModeButtons = new Map(); - const urlPatternModeToggle = h('div', { - className: 'sdt-hm-mode', - role: 'radiogroup', - 'aria-label': 'URL pattern matching mode', - }); - const urlModeOptions: Array<[ClickmapUrlPatternMode, string, string]> = [ - ['glob', '*', 'Glob matching — * is the only wildcard'], - ['regex', '.*', 'Regex matching — full RE2 syntax'], - ]; - function applyUrlPatternMode() { - for (const [mode, btn] of urlModeButtons) { - const active = mode === filters.urlPatternMode; - btn.setAttribute('aria-checked', String(active)); - btn.classList.toggle('sdt-hm-mode-btn-active', active); - } - urlPatternInput.placeholder = filters.urlPatternMode === 'regex' ? '^/products/.*$' : '/products/*'; - renderUrlHelp(); - } - function setUrlPatternMode(mode: ClickmapUrlPatternMode) { - if (filters.urlPatternMode === mode) return; - let nextPattern: string; - if (mode === 'regex') { - // Seed regex mode from the current glob, translated to an equivalent - // anchored regex, so the switch leaves a working starting point. - const current = getEffectiveUrlPattern(); - nextPattern = current === '' ? '' : `^${globToRegexSource(current)}$`; - urlPatternUserEdited = nextPattern !== ''; - urlPatternInput.value = nextPattern; - } else { - // A regex can't be reversed into a glob, so fall back to auto route-tracking. - nextPattern = ''; - urlPatternUserEdited = false; - urlPatternInput.value = wildcardizePathname(window.location.pathname); - } - filters = { ...filters, urlPatternMode: mode, urlPattern: nextPattern }; - persistFilters(filters); - applyUrlPatternMode(); - scheduleFilterReload(); - scheduleRender(); - } - for (const [mode, label, title] of urlModeOptions) { - const btn = h('button', { className: 'sdt-hm-mode-btn', type: 'button', role: 'radio', title }, label) as HTMLButtonElement; - btn.addEventListener('click', () => setUrlPatternMode(mode)); - urlModeButtons.set(mode, btn); - urlPatternModeToggle.appendChild(btn); - } - applyUrlPatternMode(); + renderUrlHelp(); const elementSearchInput = h('input', { className: 'sdt-hm-filter-input', @@ -2604,11 +2586,11 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR setHtml(clicksIcon, clicksIconSvg); setHtml(elementsIcon, elementsIconSvg); const toolbar = h('div', { className: 'sdt-hm-toolbar' }, - backButton, + closeButton, h('div', { className: 'sdt-hm-toolbar-title' }, 'Clickmap'), h('div', { className: 'sdt-hm-toolbar-filters' }, rangeSelect, - h('div', { className: 'sdt-hm-toolbar-url' }, urlPatternInput, urlPatternReset, urlPatternModeToggle, urlPatternInfo, urlPatternHelp), + h('div', { className: 'sdt-hm-toolbar-url' }, urlPatternInput, urlPatternReset, urlPatternInfo, urlPatternHelp), ), h('div', { className: 'sdt-hm-toolbar-metrics' }, h('span', { className: 'sdt-hm-toolbar-metric', title: 'Aggregate clicks' }, miniClicks, clicksIcon), @@ -2662,10 +2644,9 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR }); urlPatternReset.addEventListener('click', () => { // Hand control back to auto mode and reflect the current route immediately, - // so the pattern covers the page the overlay is bound to. In regex mode - // there's no auto-wildcard, so clearing the field means "this exact page". + // so the pattern covers the page the overlay is bound to. urlPatternUserEdited = false; - urlPatternInput.value = filters.urlPatternMode === 'regex' ? '' : wildcardizePathname(window.location.pathname); + urlPatternInput.value = wildcardizePathname(window.location.pathname); updateFilters({ urlPattern: '' }); }); elementSearchInput.addEventListener('input', () => { @@ -2678,7 +2659,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR // bordered bands that made the expanded panel feel congested. const actions = h('div', { className: 'sdt-hm-actions' }, stats, overlayToggle); const head = h('div', { className: 'sdt-hm-head' }, filterRow, actions); - const body = h('div', { className: 'sdt-hm-body' }, status, list); + const body = h('div', { className: 'sdt-hm-body' }, status, viewportWarning, list); const details = h('div', { className: 'sdt-hm-details' }, head, body); function getGroups(): DevToolClickGroup[] { @@ -2848,25 +2829,34 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR const mappedClicks = groups.reduce((sum, group) => sum + group.count, 0); const aggregateClicks = serverClickmap.path === currentPath ? serverClickmap.totalClicks : 0; const viewport = getClickmapViewportSize(); + const roundedViewportWidth = Math.round(viewport.width); + const roundedViewportHeight = Math.round(viewport.height); + const selectedViewportBucket = getClickmapViewportBucket(filters.device); + const viewportFilterMatches = selectedViewportBucket == null || isClickmapViewportWidthInBucket(roundedViewportWidth, selectedViewportBucket); statsCount.textContent = formatClickmapCount(aggregateClicks); selectorCount.textContent = formatClickmapCount(groups.length); - viewportValue.textContent = `${Math.round(viewport.width)}x${Math.round(viewport.height)}`; + viewportValue.textContent = `${roundedViewportWidth}x${roundedViewportHeight}`; overlayToggle.textContent = overlayVisible ? 'Hide overlay' : 'Show overlay'; + viewportWarning.classList.toggle('sdt-hm-viewport-warning-visible', !viewportFilterMatches); + if (selectedViewportBucket != null && !viewportFilterMatches) { + const recommendedWidth = getClickmapRecommendedViewportWidth(selectedViewportBucket); + const recommendedHeight = Math.max(1, roundedViewportHeight); + viewportWarningTitle.textContent = 'Viewport filter mismatch'; + viewportWarningBody.textContent = `This page is ${roundedViewportWidth}px wide, but ${filters.device} is ${formatClickmapViewportBucket(selectedViewportBucket)}. Update the Google DevTools device toolbar before comparing this clickmap.`; + viewportWarningWidthValue.textContent = String(recommendedWidth); + viewportWarningHeightValue.textContent = String(recommendedHeight); + } // A pattern that doesn't cover the current page means the overlay can't draw // here, so offer a one-click reset back to the current route. const effectiveUrlPattern = getEffectiveUrlPattern(); - const regexInvalid = filters.urlPatternMode === 'regex' && effectiveUrlPattern !== '' && !isValidRegexSource(effectiveUrlPattern); - const urlPatternMatchesPath = patternMatchesPath(effectiveUrlPattern, currentPath, filters.urlPatternMode); + const urlPatternMatchesPath = patternMatchesPath(effectiveUrlPattern, currentPath); urlPatternReset.classList.toggle('sdt-hm-filter-reset-visible', !urlPatternMatchesPath); - urlPatternInput.classList.toggle('sdt-hm-filter-input-error', regexInvalid); - const token = getClickmapTokenFromStorage(app.projectId); - const tokenOrigin = getClickmapOriginFromStorage(app.projectId); + const token = getClickmapTokenFromStorage(); + const tokenOrigin = getClickmapOriginFromStorage(); if (token == null) { status.textContent = serverClickmapError ?? 'No clickmap token in sessionStorage. Paste one from the dashboard to load this page.'; } else if (tokenOrigin != null && tokenOrigin !== window.location.origin) { status.textContent = `Token was minted for ${tokenOrigin}, but this page is ${window.location.origin}. Generate a token for this exact origin.`; - } else if (regexInvalid) { - status.textContent = 'Invalid regular expression — fix the pattern to load the clickmap.'; } else if (loadingServerClickmap) { status.textContent = 'Loading aggregate clickmap...'; } else if (serverClickmapError != null) { @@ -2887,7 +2877,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR } status.textContent = message; } - status.classList.toggle('sdt-hm-token-status-error', regexInvalid || serverClickmapError != null || (token != null && tokenOrigin != null && tokenOrigin !== window.location.origin)); + status.classList.toggle('sdt-hm-token-status-error', serverClickmapError != null || (token != null && tokenOrigin != null && tokenOrigin !== window.location.origin)); miniClicks.textContent = formatClickmapCount(aggregateClicks); miniElements.textContent = formatClickmapCount(groups.length); container.classList.toggle('sdt-hm-expanded', expanded); @@ -2906,7 +2896,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR const requestId = serverClickmapRequestId + 1; serverClickmapRequestId = requestId; const isLatestRequest = () => requestId === serverClickmapRequestId; - const token = getClickmapTokenFromStorage(app.projectId); + const token = getClickmapTokenFromStorage(); if (token == null) { serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; serverClickmapError = null; @@ -2914,7 +2904,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR render(); return; } - const tokenOrigin = getClickmapOriginFromStorage(app.projectId); + const tokenOrigin = getClickmapOriginFromStorage(); if (tokenOrigin != null && tokenOrigin !== window.location.origin) { serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; serverClickmapError = null; @@ -2923,16 +2913,6 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR return; } - // Don't round-trip a regex we know ClickHouse will reject; surface it locally. - const pendingPattern = getEffectiveUrlPattern(); - if (filters.urlPatternMode === 'regex' && pendingPattern !== '' && !isValidRegexSource(pendingPattern)) { - serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; - serverClickmapError = null; - loadingServerClickmap = false; - render(); - return; - } - loadingServerClickmap = true; serverClickmapError = null; render(); @@ -2948,11 +2928,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR until: until.toISOString(), }; if (effectiveUrlPattern !== '') { - if (filters.urlPatternMode === 'regex') { - body.route_regex = effectiveUrlPattern; - } else { - body.url_pattern = effectiveUrlPattern; - } + body.url_pattern = effectiveUrlPattern; } else { body.route_path = requestedPath; } @@ -2978,7 +2954,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR } serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; if (error instanceof Error && error.message.includes('Clickmap token does not belong to this project')) { - clearClickmapTokenStorage(app.projectId); + clearClickmapTokenStorage(); serverClickmapError = 'The stored clickmap token belongs to another project. Generate a fresh token for this project.'; } else { serverClickmapError = error instanceof Error ? error.message : 'Failed to load clickmap data'; @@ -2995,8 +2971,8 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR // navigates away with a token loaded, drop a sentinel so the dev tool on the // next page can auto-reopen straight back into the clickmap tab. const onBeforeUnloadResume = () => { - const token = getClickmapTokenFromStorage(app.projectId); - const tokenOrigin = getClickmapOriginFromStorage(app.projectId); + const token = getClickmapTokenFromStorage(); + const tokenOrigin = getClickmapOriginFromStorage(); if (token == null || (tokenOrigin != null && tokenOrigin !== window.location.origin)) { return; } @@ -3011,7 +2987,7 @@ function createClickmapsTab(app: StackClientApp, onBack: () => void): TabR overlayVisible = !overlayVisible; render(); }); - backButton.addEventListener('click', onBack); + closeButton.addEventListener('click', onClose); expandButton.addEventListener('click', () => { expanded = !expanded; render(); @@ -3511,7 +3487,6 @@ function createPanel( animateNextPanelGeometryChange(); } - panel.classList.toggle('sdt-panel-clickmap', tabId === 'clickmaps'); if (tabId === 'dashboard') { panel.classList.add('sdt-panel-fullscreen'); panel.style.width = ''; @@ -3520,11 +3495,6 @@ function createPanel( } panel.classList.remove('sdt-panel-fullscreen'); - if (tabId === 'clickmaps') { - panel.style.width = ''; - panel.style.height = ''; - return; - } panel.style.width = state.get().panelWidth + 'px'; panel.style.height = state.get().panelHeight + 'px'; } @@ -3532,7 +3502,6 @@ function createPanel( const tabs = getTabsForApp(app); const storedActiveTab = state.get().activeTab; const activeTab = tabs.some((tab) => tab.id === storedActiveTab) ? storedActiveTab : DEFAULT_STATE.activeTab; - let lastNonClickmapTab: TabId = activeTab === 'clickmaps' ? 'overview' : activeTab; applyPanelMode(activeTab); @@ -3551,9 +3520,6 @@ function createPanel( const trailingControls = h('div', { className: 'sdt-tabbar-actions' }, docsLink, closeBtn); const tabBar = createTabBar(tabs, activeTab, (id) => { - if (id !== 'clickmaps') { - lastNonClickmapTab = id as TabId; - } state.update({ activeTab: id as TabId }); applyPanelMode(id as TabId, { animate: true }); showTab(id as TabId); @@ -3579,22 +3545,6 @@ function createPanel( } } - let clickmapsCleanup: (() => void) | null = null; - // The clickmap tab installs an overlay root, a MutationObserver, and - // background polling. Unlike other panes it must not be cached-and-hidden: - // tear it down (running its cleanup) whenever we leave it, so the overlay and - // its work don't linger after the tab is closed. - function teardownClickmapsPane() { - const pane = mountedPanes.get('clickmaps'); - if (pane == null) return; - if (clickmapsCleanup != null) { - clickmapsCleanup(); - clickmapsCleanup = null; - } - pane.remove(); - mountedPanes.delete('clickmaps'); - } - function getOrCreatePane(tabId: TabId): HTMLElement { if (mountedPanes.has(tabId)) { return mountedPanes.get(tabId)!; @@ -3608,17 +3558,6 @@ function createPanel( mountTab(pane, createOverviewTab(app)); break; } - case 'clickmaps': { - const result = createClickmapsTab(app, () => { - state.update({ activeTab: lastNonClickmapTab }); - applyPanelMode(lastNonClickmapTab, { animate: true }); - showTab(lastNonClickmapTab); - }); - pane.appendChild(result.element); - // Tracked separately from `cleanups` so it can run on tab-switch, not just unmount. - clickmapsCleanup = result.cleanup ?? null; - break; - } case 'customize': { mountTab(pane, createComponentsTab(app)); break; @@ -3646,9 +3585,6 @@ function createPanel( } function showTab(tabId: TabId) { - if (tabId !== 'clickmaps') { - teardownClickmapsPane(); - } const pane = getOrCreatePane(tabId); tabBar.setActive(tabId); for (const [, p] of mountedPanes) { @@ -3713,7 +3649,6 @@ function createPanel( if (panelAnimationTimeout !== null) { clearTimeout(panelAnimationTimeout); } - teardownClickmapsPane(); for (const fn of cleanups) fn(); }, }; @@ -3763,17 +3698,6 @@ export function createDevTool(app: StackClientApp): () => void { wrapper.appendChild(panel.element); } - function openClickmapPanel() { - state.update({ activeTab: 'clickmaps', isOpen: true }); - if (panel) { - const currentPanel = panel; - panel = null; - currentPanel.cleanup(); - currentPanel.element.remove(); - } - openPanel(); - } - function closePanel() { if (!panel) return; state.update({ isOpen: false }); @@ -3800,10 +3724,42 @@ export function createDevTool(app: StackClientApp): () => void { const trigger = createTrigger(togglePanel); wrapper.appendChild(trigger.element); - // Resume the clickmap panel after navigating to a new page. While the overlay - // is mounted it drops a sentinel into sessionStorage on unload; if it's - // present here, restore the clickmap tab as the active one and reopen the - // panel so the user picks up where they left off. + // Clickmaps is an independent feature with its own mount, completely separate + // from the dev tool panel above: opening or closing one never touches the + // other, and both can be on screen at once. It's driven entirely by the + // dashboard-minted token (the update event + the navigation resume sentinel), + // never by the dev tool trigger. + let clickmapMount: { element: HTMLElement, cleanup: () => void } | null = null; + + function openClickmap() { + if (clickmapMount) return; + const result = createClickmapsTab(app, () => closeClickmap()); + // Reuse the panel's clickmap-mode chrome (bottom-center positioning, no tab + // bar) by replicating the structure those styles target. + const element = h('div', { className: 'sdt-panel sdt-panel-clickmap' }, + h('div', { className: 'sdt-panel-inner' }, + h('div', { className: 'sdt-content' }, + h('div', { className: 'sdt-tab-layers' }, + h('div', { className: 'sdt-tab-pane sdt-tab-pane-active' }, result.element), + ), + ), + ), + ); + clickmapMount = { element, cleanup: result.cleanup ?? (() => {}) }; + wrapper.appendChild(element); + } + + function closeClickmap() { + if (!clickmapMount) return; + const closing = clickmapMount; + clickmapMount = null; + closing.cleanup(); + closing.element.remove(); + } + + // Resume the clickmap after navigating to a new page. While the overlay is + // mounted it drops a sentinel into sessionStorage on unload; if it's present + // here, reopen the clickmap so the user picks up where they left off. let shouldResumeClickmap = false; try { if (sessionStorage.getItem(CLICKMAP_OVERLAY_RESUME_STORAGE_KEY) === '1') { @@ -3813,15 +3769,15 @@ export function createDevTool(app: StackClientApp): () => void { } catch { // ignore } - if (shouldResumeClickmap) { - state.update({ activeTab: 'clickmaps', isOpen: true }); - } if (state.get().isOpen) { openPanel(); } + if (shouldResumeClickmap) { + openClickmap(); + } - window.addEventListener(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, openClickmapPanel); + window.addEventListener(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, openClickmap); const removeRequestListener = app[stackAppInternalsSymbol].addRequestListener((entry: RequestLogEntry) => { const timestamp = Date.now(); @@ -3850,9 +3806,10 @@ export function createDevTool(app: StackClientApp): () => void { setGlobalDevToolInstance(null); } trigger.cleanup(); - window.removeEventListener(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, openClickmapPanel); + window.removeEventListener(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT, openClickmap); removeRequestListener(); panel?.cleanup(); + clickmapMount?.cleanup(); if (root.parentNode) { root.parentNode.removeChild(root); } diff --git a/packages/template/src/dev-tool/dev-tool-styles.ts b/packages/template/src/dev-tool/dev-tool-styles.ts index 1497fd7cc5..e066990c32 100644 --- a/packages/template/src/dev-tool/dev-tool-styles.ts +++ b/packages/template/src/dev-tool/dev-tool-styles.ts @@ -3008,26 +3008,26 @@ export const devToolCSS = ` .stack-devtool .sdt-hm-filter-reset { display: none; + flex-shrink: 0; + width: 20px; + height: 20px; + align-items: center; + justify-content: center; border: 0; + border-radius: 999px; background: transparent; padding: 0; - font: inherit; - font-family: var(--sdt-font); - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; color: var(--sdt-accent); cursor: pointer; } .stack-devtool .sdt-hm-filter-reset:hover { + background: var(--sdt-bg-hover); color: var(--sdt-accent-hover); } .stack-devtool .sdt-hm-filter-reset-visible { display: inline-flex; - align-items: center; } .stack-devtool .sdt-hm-filter-input { @@ -3149,6 +3149,88 @@ export const devToolCSS = ` color: var(--sdt-error); } + .stack-devtool .sdt-hm-viewport-warning { + display: none; + gap: 8px; + padding: 10px; + border-radius: var(--sdt-radius); + border: 1px solid rgba(234, 179, 8, 0.24); + background: var(--sdt-warning-muted); + color: var(--sdt-text); + } + + .stack-devtool .sdt-hm-viewport-warning-visible { + display: flex; + flex-direction: column; + } + + .stack-devtool .sdt-hm-viewport-warning-title { + font-size: 12px; + font-weight: 650; + color: var(--sdt-text); + line-height: 1.2; + } + + .stack-devtool .sdt-hm-viewport-warning-body { + font-size: 11.5px; + line-height: 1.45; + color: var(--sdt-text-secondary); + } + + .stack-devtool .sdt-hm-viewport-warning-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .stack-devtool .sdt-hm-viewport-warning-action { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: var(--sdt-radius); + border: 1px solid var(--sdt-border-subtle); + background: var(--sdt-bg-elevated); + padding: 4px 5px 4px 8px; + } + + .stack-devtool .sdt-hm-viewport-warning-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--sdt-text-tertiary); + } + + .stack-devtool .sdt-hm-viewport-warning-code { + font-family: var(--sdt-font-mono); + font-size: 11.5px; + font-weight: 650; + color: var(--sdt-text); + font-variant-numeric: tabular-nums; + } + + .stack-devtool .sdt-hm-copy-btn { + height: 22px; + border: 1px solid var(--sdt-border-subtle); + border-radius: 999px; + background: var(--sdt-bg); + color: var(--sdt-text-secondary); + padding: 0 8px; + font: inherit; + font-size: 10.5px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; + } + + .stack-devtool .sdt-hm-copy-btn:hover { + background: var(--sdt-bg-hover); + border-color: var(--sdt-border); + color: var(--sdt-text); + transition: none; + } + .stack-devtool .sdt-hm-list { display: flex; flex-direction: column; From 1f10e19b73f485c31cb9b969c4336d57cf7a760e Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Sat, 30 May 2026 02:08:37 -0700 Subject: [PATCH 10/10] feat(analytics): enhance clickmap UI and interaction features - Added new elements and functionality for clickmap list rows, including highlight and mute features. - Implemented event handling for row interactions, allowing users to toggle muted states and highlight groups. - Updated styles for improved visual feedback on muted and highlighted states in the clickmap overlay. - Introduced a clear function for managing clickmap list elements, enhancing performance and usability. --- .../template/src/dev-tool/dev-tool-core.ts | 142 ++++++++++++++++-- .../template/src/dev-tool/dev-tool-styles.ts | 77 ++++++++++ 2 files changed, 203 insertions(+), 16 deletions(-) diff --git a/packages/template/src/dev-tool/dev-tool-core.ts b/packages/template/src/dev-tool/dev-tool-core.ts index 6641a2c159..b4551ab29d 100644 --- a/packages/template/src/dev-tool/dev-tool-core.ts +++ b/packages/template/src/dev-tool/dev-tool-core.ts @@ -1809,6 +1809,15 @@ type DevToolClickGroup = { type ClickmapGroupOverlayElement = { marker: HTMLElement; outline: HTMLElement; + highlight: HTMLElement; +}; + +type ClickmapListRowElement = { + row: HTMLElement; + count: HTMLButtonElement; + label: HTMLElement; + selector: HTMLElement; + group: DevToolClickGroup | null; }; const CLICKMAP_FILTERS_STORAGE_KEY = 'hexclave-clickmap-overlay-filters'; @@ -2171,7 +2180,10 @@ function createClickmapsTab(app: StackClientApp, onClose: () => void): Tab let expanded = false; let renderFrame = 0; let overlayMode: 'hidden' | 'elements' = 'hidden'; + let highlightedGroupSelector: string | null = null; + const mutedGroupSelectors = new Set(); const groupOverlayElements = new Map(); + const listRowElements = new Map(); function resetCopyButton(button: HTMLElement, label: string) { button.textContent = label; @@ -2732,6 +2744,11 @@ function createClickmapsTab(app: StackClientApp, onClose: () => void): Tab overlayRoot.replaceChildren(); } + function clearClickmapListElements() { + listRowElements.clear(); + list.replaceChildren(); + } + function getClickmapViewportSize(): { width: number, height: number } { const visualViewport = window.visualViewport; if (visualViewport != null) { @@ -2744,6 +2761,66 @@ function createClickmapsTab(app: StackClientApp, onClose: () => void): Tab return overlayVisible; } + function toggleMutedGroup(selector: string) { + if (mutedGroupSelectors.has(selector)) { + mutedGroupSelectors.delete(selector); + } else { + mutedGroupSelectors.add(selector); + } + scheduleRender(); + } + + function highlightGroup(group: DevToolClickGroup) { + highlightedGroupSelector = group.selector; + group.element?.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' }); + scheduleRender(); + } + + function createListRowElement(selector: string): ClickmapListRowElement { + const count = h('button', { className: 'sdt-hm-row-count', type: 'button' }) as HTMLButtonElement; + const label = h('span', { className: 'sdt-hm-row-label' }); + const selectorText = h('span', { className: 'sdt-hm-row-selector' }); + const row = h('div', { + className: 'sdt-hm-row', + role: 'button', + tabindex: '0', + }, + count, + h('span', { className: 'sdt-hm-row-meta' }, label, selectorText), + ); + const rowElement: ClickmapListRowElement = { row, count, label, selector: selectorText, group: null }; + count.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + toggleMutedGroup(selector); + }); + row.addEventListener('click', () => { + if (rowElement.group == null) return; + highlightGroup(rowElement.group); + }); + row.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + if (rowElement.group == null) return; + highlightGroup(rowElement.group); + }); + return rowElement; + } + + function updateListRowElement(rowElement: ClickmapListRowElement, group: DevToolClickGroup) { + const muted = mutedGroupSelectors.has(group.selector); + const highlighted = highlightedGroupSelector === group.selector; + rowElement.group = group; + rowElement.row.classList.toggle('sdt-hm-row-muted', muted); + rowElement.row.classList.toggle('sdt-hm-row-highlighted', highlighted); + rowElement.count.textContent = formatClickmapCount(group.count); + rowElement.count.setAttribute('aria-pressed', String(muted)); + rowElement.count.setAttribute('aria-label', muted ? `Unmute ${group.label}` : `Mute ${group.label}`); + rowElement.count.title = muted ? 'Unmute element' : 'Mute element'; + rowElement.label.textContent = group.label; + rowElement.selector.textContent = group.selector; + } + function renderOverlay(groups: DevToolClickGroup[]) { const nextMode = shouldShowElements() ? 'elements' : 'hidden'; if (overlayMode !== nextMode) { @@ -2762,56 +2839,81 @@ function createClickmapsTab(app: StackClientApp, onClose: () => void): Tab } visibleGroupKeys.add(group.selector); const hue = getClickmapHue(group.count, maxCount); + const muted = mutedGroupSelectors.has(group.selector); + const highlighted = highlightedGroupSelector === group.selector; let overlayElement = groupOverlayElements.get(group.selector); if (overlayElement == null) { + const marker = h('button', { className: 'sdt-hm-marker', type: 'button', tabindex: '-1' }); + marker.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + toggleMutedGroup(group.selector); + }); overlayElement = { - marker: h('div', { className: 'sdt-hm-marker' }), + marker, outline: h('div', { className: 'sdt-hm-outline' }), + highlight: h('div', { className: 'sdt-hm-highlight' }), }; groupOverlayElements.set(group.selector, overlayElement); - overlayRoot.append(overlayElement.outline, overlayElement.marker); + overlayRoot.append(overlayElement.highlight, overlayElement.outline, overlayElement.marker); } - const { marker, outline } = overlayElement; - marker.title = `${group.count} clicks on ${group.selector}`; + const { marker, outline, highlight } = overlayElement; + marker.title = muted ? `Unmute ${group.selector}` : `Mute ${group.count} clicks on ${group.selector}`; + marker.setAttribute('aria-label', marker.title); marker.style.left = `${Math.round(group.rect.left + group.rect.width / 2)}px`; marker.style.top = `${Math.round(group.rect.top + group.rect.height / 2)}px`; marker.style.background = `hsla(${hue}, 96%, 58%, 0.94)`; marker.style.boxShadow = `0 0 0 1px hsla(${hue}, 96%, 22%, 0.35), 0 8px 24px hsla(${hue}, 96%, 45%, 0.32)`; marker.textContent = formatClickmapCount(group.count); + marker.classList.toggle('sdt-hm-marker-muted', muted); + marker.classList.toggle('sdt-hm-marker-highlighted', highlighted); outline.style.left = `${group.rect.left}px`; outline.style.top = `${group.rect.top}px`; outline.style.width = `${group.rect.width}px`; outline.style.height = `${group.rect.height}px`; outline.style.borderColor = `hsla(${hue}, 96%, 58%, 0.5)`; + outline.classList.toggle('sdt-hm-outline-muted', muted); + outline.classList.toggle('sdt-hm-outline-highlighted', highlighted); + + highlight.style.left = `${group.rect.left}px`; + highlight.style.top = `${group.rect.top}px`; + highlight.style.width = `${group.rect.width}px`; + highlight.style.height = `${group.rect.height}px`; + highlight.classList.toggle('sdt-hm-highlight-visible', highlighted); } for (const [key, overlayElement] of groupOverlayElements) { if (!visibleGroupKeys.has(key)) { overlayElement.marker.remove(); overlayElement.outline.remove(); + overlayElement.highlight.remove(); groupOverlayElements.delete(key); } } } function renderList(groups: DevToolClickGroup[]) { - list.replaceChildren(); if (groups.length === 0) { + clearClickmapListElements(); list.appendChild(empty); return; } + empty.remove(); + const renderedKeys = new Set(); for (const group of groups.slice(0, 30)) { - const row = h('button', { className: 'sdt-hm-row' }); - row.appendChild(h('span', { className: 'sdt-hm-row-count' }, formatClickmapCount(group.count))); - const meta = h('span', { className: 'sdt-hm-row-meta' }, - h('span', { className: 'sdt-hm-row-label' }, group.label), - h('span', { className: 'sdt-hm-row-selector' }, group.selector), - ); - row.appendChild(meta); - row.addEventListener('click', () => { - group.element?.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' }); - }); - list.appendChild(row); + renderedKeys.add(group.selector); + let rowElement = listRowElements.get(group.selector); + if (rowElement == null) { + rowElement = createListRowElement(group.selector); + listRowElements.set(group.selector, rowElement); + } + updateListRowElement(rowElement, group); + list.appendChild(rowElement.row); + } + for (const [selector, rowElement] of listRowElements) { + if (renderedKeys.has(selector)) continue; + rowElement.row.remove(); + listRowElements.delete(selector); } } @@ -2820,10 +2922,18 @@ function createClickmapsTab(app: StackClientApp, onClose: () => void): Tab currentPath = window.location.pathname; serverClickmap = { path: currentPath, totalClicks: 0, selectors: [], elements: [] }; serverClickmapError = null; + clearClickmapListElements(); syncAutoUrlPattern(); runAsynchronously(loadServerClickmap()); } const groups = getGroups(); + const groupKeys = new Set(groups.map((group) => group.selector)); + for (const mutedGroupSelector of mutedGroupSelectors) { + if (!groupKeys.has(mutedGroupSelector)) mutedGroupSelectors.delete(mutedGroupSelector); + } + if (highlightedGroupSelector != null && !groupKeys.has(highlightedGroupSelector)) { + highlightedGroupSelector = null; + } // Clicks mapped to an element that actually exists in the current DOM (what // the overlay can draw) vs. the true aggregate the filter matched server-side. const mappedClicks = groups.reduce((sum, group) => sum + group.count, 0); diff --git a/packages/template/src/dev-tool/dev-tool-styles.ts b/packages/template/src/dev-tool/dev-tool-styles.ts index e066990c32..3cf12b636f 100644 --- a/packages/template/src/dev-tool/dev-tool-styles.ts +++ b/packages/template/src/dev-tool/dev-tool-styles.ts @@ -3264,10 +3264,25 @@ export const devToolCSS = ` text-align: left; cursor: pointer; font-family: var(--sdt-font); + user-select: none; } .stack-devtool .sdt-hm-row:hover { background: var(--sdt-bg-hover); + transition: none; + } + + .stack-devtool .sdt-hm-row:focus-visible { + outline: 2px solid var(--sdt-accent); + outline-offset: 2px; + } + + .stack-devtool .sdt-hm-row-muted { + opacity: 0.52; + } + + .stack-devtool .sdt-hm-row-highlighted { + background: rgba(250, 204, 21, 0.12); } .stack-devtool .sdt-hm-row-count { @@ -3279,9 +3294,31 @@ export const devToolCSS = ` border-radius: 999px; background: var(--sdt-accent-muted); color: var(--sdt-accent-hover); + border: 0; + appearance: none; + padding: 0; font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums; + font-family: var(--sdt-font); + cursor: pointer; + } + + .stack-devtool .sdt-hm-row-count:hover { + background: var(--sdt-bg-hover); + color: var(--sdt-text); + transition: none; + } + + .stack-devtool .sdt-hm-row-count[aria-pressed="true"] { + background: var(--sdt-bg-elevated); + color: var(--sdt-text-tertiary); + text-decoration: line-through; + } + + .stack-devtool .sdt-hm-row-count:focus-visible { + outline: 2px solid var(--sdt-accent); + outline-offset: 2px; } .stack-devtool .sdt-hm-row-meta { @@ -3325,9 +3362,28 @@ export const devToolCSS = ` display: flex; align-items: center; justify-content: center; + border: 0; color: rgba(10, 10, 11, 0.92); font: 700 12px/1 var(--sdt-font, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); font-variant-numeric: tabular-nums; + cursor: pointer; + pointer-events: auto; + transition: opacity 0.15s ease, transform 0.15s ease, filter 0.15s ease; + } + + .sdt-hm-overlay-root .sdt-hm-marker:hover { + transform: translate(-50%, -50%) scale(1.06); + transition: none; + } + + .sdt-hm-overlay-root .sdt-hm-marker-muted { + opacity: 0.18; + filter: saturate(0.25); + text-decoration: line-through; + } + + .sdt-hm-overlay-root .sdt-hm-marker-highlighted { + transform: translate(-50%, -50%) scale(1.08); } .sdt-hm-overlay-root .sdt-hm-outline { @@ -3335,6 +3391,27 @@ export const devToolCSS = ` border: 1px solid; border-radius: 4px; background: rgba(99, 102, 241, 0.04); + transition: opacity 0.15s ease, background 0.15s ease, border-color 0.15s ease; + } + + .sdt-hm-overlay-root .sdt-hm-outline-muted { + opacity: 0; + } + + .sdt-hm-overlay-root .sdt-hm-outline-highlighted { + border-color: rgba(250, 204, 21, 0.92) !important; + } + + .sdt-hm-overlay-root .sdt-hm-highlight { + position: fixed; + display: none; + border-radius: 5px; + background: rgba(250, 204, 21, 0.28); + box-shadow: 0 0 0 1px rgba(250, 204, 21, 0.7), 0 0 0 9999px rgba(0, 0, 0, 0.04); + } + + .sdt-hm-overlay-root .sdt-hm-highlight-visible { + display: block; } /* --- Input area --- */