diff --git a/apps/backend/scripts/clickhouse-migrations.ts b/apps/backend/scripts/clickhouse-migrations.ts index 8db2a40777..830b78d7c3 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/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 +// 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/clickmap/route.ts b/apps/backend/src/app/api/latest/analytics/clickmap/route.ts new file mode 100644 index 0000000000..f33f7e28b6 --- /dev/null +++ b/apps/backend/src/app/api/latest/analytics/clickmap/route.ts @@ -0,0 +1,121 @@ +import { + type ClickmapClicksQueryResult, + parseBoundedDateTime, + runClickmapClicksQuery, + throwClickhouseClickmapError, +} from "@/lib/analytics-clickmap-query"; +import { verifyAnalyticsClickmapToken } from "@/lib/analytics-clickmap-tokens"; +import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +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"; + +const MAX_WINDOW_DAYS = 31; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const ROUTE_LIMIT = 50; +const ELEMENTS_CHAIN_LIMIT = 200; + +export const POST = createSmartRouteHandler({ + metadata: { + 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, + }, + request: yupObject({ + auth: yupObject({ + type: clientOrHigherAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + user: adaptSchema.optional(), + }).defined(), + body: yupObject({ + clickmap_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: AnalyticsClickmapResponseBodySchema, + }), + handler: async ({ body }) => { + // 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 clickmapToken = await verifyAnalyticsClickmapToken({ + token: body.clickmap_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, `Clickmap window cannot exceed ${MAX_WINDOW_DAYS} days`); + } + + const client = getClickhouseAdminClientForMetrics(); + let result: ClickmapClicksQueryResult; + try { + result = await runClickmapClicksQuery(client, { + projectId: clickmapToken.project_id, + branchId: clickmapToken.branch_id, + since, + until, + origin: clickmapToken.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) { + throwClickhouseClickmapError(error, { + captureLabel: "analytics-clickmap-clickhouse-fallback", + routeRegex: body.route_regex, + 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: AnalyticsClickmapResponse = { + kind: "session_replay_clicks", + cells: [], + sampling: result.samplingPct / 100, + routes: result.routes, + users: [], + replays: [], + selectors: result.selectors, + elements: result.elements, + }; + + return { + statusCode: 200, + bodyType: "json", + body: responseBody, + }; + }, +}); diff --git a/apps/backend/src/app/api/latest/internal/analytics/clickmap-token/route.ts b/apps/backend/src/app/api/latest/internal/analytics/clickmap-token/route.ts new file mode 100644 index 0000000000..c7b70e804d --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/analytics/clickmap-token/route.ts @@ -0,0 +1,34 @@ +import { createAnalyticsClickmapToken } from "@/lib/analytics-clickmap-tokens"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +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({ + 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: AnalyticsClickmapTokenResponseBodySchema, + }), + handler: async ({ auth, body }) => { + const token = await createAnalyticsClickmapToken({ 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/clickmap/route.ts b/apps/backend/src/app/api/latest/internal/analytics/clickmap/route.ts new file mode 100644 index 0000000000..189882f87f --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/analytics/clickmap/route.ts @@ -0,0 +1,195 @@ +import { + buildHourOfWeekClickmapCells, + type ClickmapClicksQueryResult, + formatClickhouseDateTimeParam, + parseBoundedDateTime, + runClickmapClicksQuery, + 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 { 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"; +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; + +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(), + 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 ClickmapRequestBody = yup.InferType; + +function emptyClickmapResponse(kind: AnalyticsClickmapResponse["kind"], cells: AnalyticsClickmapResponse["cells"]): AnalyticsClickmapResponse { + return { kind, cells, sampling: 1, routes: [], users: [], replays: [], selectors: [], elements: [] }; +} + +async function handleClickClickmap(tenancy: Tenancy, body: ClickmapRequestBody, 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) { + throwClickhouseClickmapError(error, { + captureLabel: "internal-analytics-clickmap-clickhouse-fallback", + routeRegex: body.route_regex, + context: { projectId: tenancy.project.id, branchId: tenancy.branchId, kind: body.kind }, + }); + } + + 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, + }]; + })); + + return { + 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, + }; +} + +async function handleTeamHourOfWeek(tenancy: Tenancy, body: ClickmapRequestBody, since: Date, until: Date): Promise { + if (body.member_user_ids.length === 0) { + return emptyClickmapResponse(body.kind, buildHourOfWeekClickmapCells([])); + } + + 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 emptyClickmapResponse(body.kind, buildHourOfWeekClickmapCells(rows)); + } catch (error) { + throwClickhouseClickmapError(error, { + captureLabel: "internal-analytics-clickmap-clickhouse-fallback", + context: { projectId: tenancy.project.id, branchId: tenancy.branchId, kind: body.kind }, + }); + } +} + +export const POST = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }), + body: clickmapRequestBodySchema, + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: AnalyticsClickmapResponseBodySchema, + }), + 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, `Query window cannot exceed ${MAX_WINDOW_DAYS} days`); + } + + const responseBody = body.kind === "session_replay_clicks" + ? 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 new file mode 100644 index 0000000000..d77e3abae5 --- /dev/null +++ b/apps/backend/src/lib/analytics-clickmap-query.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { + buildClickmapUrlLikePattern, + buildHourOfWeekClickmapCells, + clampClickmapSampling, + getClickmapOriginFilter, + getClickmapOriginParams, + getClickmapRouteFilter, + getClickmapSystemElementFilter, + getClickmapUserAndReplayFilter, + getClickmapViewportFilter, + getDeviceViewportBucket, +} from "./analytics-clickmap-query"; + +describe("analytics clickmap query helpers", () => { + it("pads sparse hour-of-week rows into a complete 7x24 grid", () => { + const cells = buildHourOfWeekClickmapCells([ + { weekday: "1", hour: "0", value: "3" }, + { weekday: 7, hour: 23, value: 9 }, + ]); + + expect(cells).toHaveLength(168); + expect(cells[0]).toMatchInlineSnapshot(` + { + "hour": 0, + "value": 3, + "weekday": 1, + } + `); + expect(cells[167]).toMatchInlineSnapshot(` + { + "hour": 23, + "value": 9, + "weekday": 7, + } + `); + expect(cells[1]).toMatchInlineSnapshot(` + { + "hour": 1, + "value": 0, + "weekday": 1, + } + `); + }); + + it("ignores invalid ClickHouse bucket rows", () => { + const cells = buildHourOfWeekClickmapCells([ + { weekday: 0, hour: 12, value: 10 }, + { weekday: 1, hour: 24, value: 10 }, + { weekday: 2, hour: 3, value: 4 }, + ]); + + expect(cells.find((cell) => cell.weekday === 2 && cell.hour === 3)).toMatchInlineSnapshot(` + { + "hour": 3, + "value": 4, + "weekday": 2, + } + `); + expect(cells.filter((cell) => cell.value !== 0)).toHaveLength(1); + }); + + it("returns no viewport bucket when no device is selected", () => { + expect(getDeviceViewportBucket(undefined)).toBeNull(); + expect(getDeviceViewportBucket("")).toBeNull(); + expect(getDeviceViewportBucket("not-a-device")).toBeNull(); + }); + + it("expands device classes into viewport bucket bounds", () => { + expect(getDeviceViewportBucket("mobile")).toMatchInlineSnapshot(` + { + "max": 767, + "min": 0, + } + `); + expect(getDeviceViewportBucket("widescreen")).toMatchInlineSnapshot(` + { + "max": 1919, + "min": 1440, + } + `); + }); + + it("emits a viewport filter only for the bounds that are set", () => { + expect(getClickmapViewportFilter(undefined, undefined)).toMatchInlineSnapshot(`""`); + expect(getClickmapViewportFilter(768, undefined)).toMatchInlineSnapshot(`"AND viewport_width >= {viewportWidthMin:UInt32}"`); + expect(getClickmapViewportFilter(undefined, 1023)).toMatchInlineSnapshot(`"AND viewport_width <= {viewportWidthMax:UInt32}"`); + expect(getClickmapViewportFilter(768, 1023)).toMatchInlineSnapshot(`"AND viewport_width >= {viewportWidthMin:UInt32} AND viewport_width <= {viewportWidthMax:UInt32}"`); + }); + + it("prefers a route regex over a url pattern over an exact route", () => { + expect(getClickmapRouteFilter("/x", "^/x", "/x/%")).toMatchInlineSnapshot(`"AND match(path, {routeRegex:String})"`); + expect(getClickmapRouteFilter("/x", undefined, "/x/%")).toMatchInlineSnapshot(`"AND path LIKE {urlPatternLike:String}"`); + expect(getClickmapRouteFilter("/x", undefined, null)).toMatchInlineSnapshot(`"AND path = {routePath:String}"`); + expect(getClickmapRouteFilter(undefined, undefined, null)).toMatchInlineSnapshot(`""`); + }); + + it("translates `*` wildcards into SQL LIKE while escaping `_`/`%`/`\\\\`", () => { + expect(buildClickmapUrlLikePattern(undefined)).toBeNull(); + expect(buildClickmapUrlLikePattern("")).toBeNull(); + expect(buildClickmapUrlLikePattern("/products/*")).toMatchInlineSnapshot(`"/products/%"`); + expect(buildClickmapUrlLikePattern("/path%/_*")).toMatchInlineSnapshot(`"/path\\%/\\_%"`); + expect(buildClickmapUrlLikePattern("/api/v*/users/*")).toMatchInlineSnapshot(`"/api/v%/users/%"`); + }); + + it("binds clickmap user/replay filters as nullable to match the MV schema", () => { + expect(getClickmapUserAndReplayFilter("user-123", "replay-123")).toMatchInlineSnapshot(`"AND user_id = {userId:Nullable(String)} AND session_replay_id = {replayId:Nullable(String)}"`); + expect(getClickmapUserAndReplayFilter("user-123", undefined)).toMatchInlineSnapshot(`"AND user_id = {userId:Nullable(String)}"`); + expect(getClickmapUserAndReplayFilter(undefined, "replay-123")).toMatchInlineSnapshot(`"AND session_replay_id = {replayId:Nullable(String)}"`); + expect(getClickmapUserAndReplayFilter(undefined, undefined)).toMatchInlineSnapshot(`""`); + }); + + it("scopes public clickmap queries to the exact token origin", () => { + expect(getClickmapOriginFilter()).toMatchInlineSnapshot(`"AND (url = {origin:String} OR startsWith(url, {originSlashPrefix:String}) OR startsWith(url, {originQueryPrefix:String}) OR startsWith(url, {originHashPrefix:String}))"`); + expect(getClickmapOriginParams("https://app.example.com")).toMatchInlineSnapshot(` + { + "origin": "https://app.example.com", + "originHashPrefix": "https://app.example.com#", + "originQueryPrefix": "https://app.example.com?", + "originSlashPrefix": "https://app.example.com/", + } + `); + }); + + it("excludes Hexclave dev tool clicks from clickmap queries", () => { + expect(getClickmapSystemElementFilter()).toMatchInlineSnapshot(`"AND position(elements_chain, '__hexclave-dev-tool-root') = 0 AND position(elements_chain, 'stack-devtool') = 0 AND position(elements_chain, 'sdt-') = 0 AND position(selector, '#__hexclave-dev-tool-root') = 0 AND position(selector, '.stack-devtool') = 0 AND position(selector, '.sdt-') = 0"`); + }); + + it("clamps sampling to (0, 1] with finite default", () => { + expect(clampClickmapSampling(undefined)).toBe(1); + expect(clampClickmapSampling(0)).toBe(0.01); + expect(clampClickmapSampling(-1)).toBe(0.01); + expect(clampClickmapSampling(0.25)).toBe(0.25); + expect(clampClickmapSampling(2)).toBe(1); + expect(clampClickmapSampling(Number.NaN)).toBe(1); + }); +}); 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..4d715ba5c3 --- /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/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"; + +// --------------------------------------------------------------------------- +// 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 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 throwClickhouseClickmapError(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 data due to ClickHouse query failure.", + { cause: error, ...options.context }, + )); + throw new StatusError(StatusError.ServiceUnavailable, "Analytics data 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 / 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 { + 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 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); + 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/backend/src/lib/analytics-clickmap-tokens.test.ts b/apps/backend/src/lib/analytics-clickmap-tokens.test.ts new file mode 100644 index 0000000000..919c818fe2 --- /dev/null +++ b/apps/backend/src/lib/analytics-clickmap-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 { normalizeAnalyticsClickmapOrigin, verifyAnalyticsClickmapToken } from "./analytics-clickmap-tokens"; + +describe("analytics clickmap token helpers", () => { + it("normalizes a trusted-domain URL to its origin", () => { + expect(normalizeAnalyticsClickmapOrigin("https://example.com/dashboard?x=1")).toMatchInlineSnapshot(`"https://example.com"`); + }); + + it("rejects non-HTTP origins", () => { + expect(() => normalizeAnalyticsClickmapOrigin("javascript:alert(1)")).toThrow(StatusError); + }); + + it("returns the project encoded in a valid clickmap token", async () => { + const token = await signJWT({ + issuer: "hexclave:analytics:clickmap", + audience: "hexclave:analytics:clickmap-overlay", + expirationTime: "24h", + payload: { + kind: "analytics_clickmap_overlay", + scope: "clickmap:read", + project_id: "internal", + branch_id: "main", + origin: "http://localhost:8101", + }, + }); + + const payload = await verifyAnalyticsClickmapToken({ + token, + origin: "http://localhost:8101/projects/internal/analytics/clickmaps", + }); + + 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_clickmap_overlay", + "origin": "http://localhost:8101", + "project_id": "internal", + "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/route-handlers/smart-route-handler.tsx b/apps/backend/src/route-handlers/smart-route-handler.tsx index fc1e0146bd..3774dff2bd 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/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/clickmaps/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/page-client.tsx new file mode 100644 index 0000000000..cd4d3c82d6 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/page-client.tsx @@ -0,0 +1,496 @@ +"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, + 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 { AnalyticsClickmapDevice, AnalyticsClickmapResponse, AnalyticsClickmapTokenResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics"; +import { + 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 ClickmapOrigin = { + id: string, + origin: string, +}; + +type RangeKey = "24h" | "7d" | "30d"; +type DeviceFilterKey = "all" | AnalyticsClickmapDevice; + +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 = AnalyticsClickmapResponse["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.urlPattern = trimmedPattern; + } + if (device !== "all") { + options.device = device; + } + setLoading(true); + setError(null); + adminApp.getAnalyticsClickmap(options) + .then((response) => { + if (cancelled) return; + setData(response); + }) + .catch((err: unknown) => { + if (cancelled) return; + // Avoid surfacing raw error messages to users; show a safe generic message. + setError("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; + } +} + +// 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(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY)}, ${JSON.stringify(token)});`, + `window.dispatchEvent(new Event(${JSON.stringify(CLICKMAP_OVERLAY_TOKEN_UPDATED_EVENT)}));`, + ].join("\n"); +} + +function installClickmapTokenForCurrentOrigin(token: AnalyticsClickmapTokenResponse): boolean { + if (token.origin !== window.location.origin) { + return false; + } + try { + 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."); + return false; + } +} + +function ClickmapTokenDialog(props: { + origin: ClickmapOrigin | null, + token: AnalyticsClickmapTokenResponse | null, + autoCopied?: boolean, + open: boolean, + onOpenChange: (open: boolean) => void, +}) { + const snippet = props.token == null ? "" : createConsoleSnippet(props.token.token); + + return ( + + + + 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 clickmap token... + ) : ( + <> + + + The site will use normal client authentication plus this origin-bound clickmap 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 [autoCopied, setAutoCopied] = useState(false); + const [customOrigin, setCustomOrigin] = useState(""); + + useEffect(() => { + setCustomOrigin(window.location.origin); + }, []); + + 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 showClickmap(origin: ClickmapOrigin) { + setSelectedOrigin(origin); + setToken(null); + setDialogOpen(true); + let created: AnalyticsClickmapTokenResponse; + try { + 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 + // runAsynchronouslyWithAlert surface the error to the user. + setToken(null); + setDialogOpen(false); + throw error; + } + setToken(created); + installClickmapTokenForCurrentOrigin(created); + setAutoCopied(false); + try { + await navigator.clipboard.writeText(createConsoleSnippet(created.token)); + setAutoCopied(true); + } 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 clickmap. + + ) : ( + + {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/clickmaps/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/page.tsx new file mode 100644 index 0000000000..84cdebde14 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/clickmaps/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..d04d8954ee 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 @@ -27,8 +27,8 @@ import { UserPageMetricCard } from "../../users/[userId]/user-page-metric-card"; import { UserPageTableSection } from "../../users/[userId]/user-page-table-section"; const ANALYTICS_WINDOW_DAYS = 30; -const HEATMAP_WINDOW_DAYS = 28; // 4 full weeks — clean weekly average -const HEATMAP_WEEKS = HEATMAP_WINDOW_DAYS / 7; +const CLICKMAP_WINDOW_DAYS = 28; // 4 full weeks — clean weekly average +const CLICKMAP_WEEKS = CLICKMAP_WINDOW_DAYS / 7; const TOP_CONTRIBUTORS_LIMIT = 10; function toClickhouseDateTimeParam(date: Date): string { @@ -65,7 +65,7 @@ type DauRow = { events: number, }; -type HeatmapRow = { +type ClickmapRow = { // ClickHouse `toDayOfWeek` returns 1=Mon..7=Sun dow: number, hour: number, @@ -82,7 +82,7 @@ type ContributorRow = { type AnalyticsData = { summary: SummaryRow, dau: DauRow[], - heatmap: HeatmapRow[], + clickmap: ClickmapRow[], contributors: ContributorRow[], }; @@ -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) { @@ -282,7 +259,7 @@ export function TeamAnalyticsSection({ team }: { team: ServerTeam }) { prev_active_users_7d: 0, }, dau: [], - heatmap: [], + clickmap: [], contributors: [], }, }); @@ -292,7 +269,7 @@ export function TeamAnalyticsSection({ team }: { team: ServerTeam }) { const now = new Date(); const since = new Date(now.getTime() - ANALYTICS_WINDOW_DAYS * 24 * 60 * 60 * 1000); const since7d = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - const heatmapSince = new Date(now.getTime() - HEATMAP_WINDOW_DAYS * 24 * 60 * 60 * 1000); + const clickmapSince = new Date(now.getTime() - CLICKMAP_WINDOW_DAYS * 24 * 60 * 60 * 1000); const prevSince = new Date(since.getTime() - ANALYTICS_WINDOW_DAYS * 24 * 60 * 60 * 1000); const prev7dSince = new Date(since7d.getTime() - 7 * 24 * 60 * 60 * 1000); const baseParams = { @@ -323,17 +300,18 @@ export function TeamAnalyticsSection({ team }: { team: ServerTeam }) { prev7dSince: toClickhouseDateTimeParam(prev7dSince), }), runQuery(DAU_QUERY, baseParams), - runQuery(HEATMAP_QUERY, { - memberIds, - since: toClickhouseDateTimeParam(heatmapSince), - until: toClickhouseDateTimeParam(now), + stackAdminApp.getAnalyticsClickmap({ + kind: "team_user_hour_of_week", + memberUserIds: memberIds, + since: clickmapSince.toISOString(), + until: now.toISOString(), }), runQuery(TOP_CONTRIBUTORS_QUERY, { ...baseParams, limit: TOP_CONTRIBUTORS_LIMIT }), ]); if (token.cancelled) return; - const queryNames = ["summary", "dau", "heatmap", "contributors"] as const; + const queryNames = ["summary", "dau", "clickmap", "contributors"] as const; for (const [i, res] of results.entries()) { if (res.status === "rejected") { captureError(`team-analytics-query:${queryNames[i]}`, res.reason); @@ -344,14 +322,16 @@ export function TeamAnalyticsSection({ team }: { team: ServerTeam }) { return; } - const [summaryRes, dauRes, heatmapRes, contributorsRes] = results; + const [summaryRes, dauRes, clickmapRes, contributorsRes] = results; setState({ status: "ready", 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) : [], + clickmap: clickmapRes.status === "fulfilled" + ? clickmapRes.value.cells.map((cell) => ({ dow: cell.weekday, hour: cell.hour, active_users: cell.value })) + : [], contributors: contributorsRes.status === "fulfilled" ? parseContributors(contributorsRes.value.result) : [], }, }); @@ -475,7 +455,7 @@ function TeamAnalyticsLoaded({ data, members }: { data: AnalyticsData, members: />
- + @@ -487,7 +467,7 @@ function TeamAnalyticsLoaded({ data, members }: { data: AnalyticsData, members: const DOW_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const; const HOUR_AXIS_TICKS = [0, 4, 8, 12, 16, 20] as const; -function HourOfWeekHeatmap({ rows, hasAnyEvent }: { rows: HeatmapRow[], hasAnyEvent: boolean }) { +function HourOfWeekClickmap({ rows, hasAnyEvent }: { rows: ClickmapRow[], hasAnyEvent: boolean }) { const { grid, max } = useMemo(() => { const g: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0) as number[]); let m = 0; @@ -505,7 +485,7 @@ function HourOfWeekHeatmap({ rows, hasAnyEvent }: { rows: HeatmapRow[], hasAnyEv {!hasAnyEvent ? (
@@ -553,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`}
@@ -564,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/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/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index 82f6c86c32..91442d0efd 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: "Clickmaps", href: "./clickmaps" }, { displayName: "Queries", href: "./queries" }, ], screenshots: [], 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" }, 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 71df733337..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 { 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,6 +402,48 @@ export class HexclaveAdminInterface extends HexclaveServerInterface { return (await response.json()) as UserActivityResponse; } + 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?: AnalyticsClickmapDevice, + viewport_width_min?: number, + viewport_width_max?: number, + sampling?: number, + since: string, + until: string, + }): Promise { + const response = await this.sendAdminRequest( + "/internal/analytics/clickmap", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(options), + }, + null, + ); + return (await response.json()) as AnalyticsClickmapResponse; + } + + async createAnalyticsClickmapToken(options: { + origin: string, + }): Promise { + const response = await this.sendAdminRequest( + "/internal/analytics/clickmap-token", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(options), + }, + null, + ); + return (await response.json()) as AnalyticsClickmapTokenResponse; + } + 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..24b24344e4 100644 --- a/packages/stack-shared/src/interface/admin-metrics.ts +++ b/packages/stack-shared/src/interface/admin-metrics.ts @@ -172,13 +172,74 @@ 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 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 AnalyticsClickmapTokenResponseBodySchema = yupObject({ + token: yupString().defined(), + origin: yupString().defined(), + expires_at_millis: yupNumber().integer().defined(), +}).defined(); + +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 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), + 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,27 @@ export type MetricsRecentUser = yup.InferType; export type MetricsResponse = yup.InferType; export type MetricsUserCounts = yup.InferType; export type UserActivityResponse = 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 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 AnalyticsClickmapOptions = { + kind: AnalyticsClickmapKind, + memberUserIds?: string[], + routePath?: string, + routeRegex?: string, + urlPattern?: string, + userId?: string, + replayId?: string, + device?: AnalyticsClickmapDevice, + viewportWidthMin?: number, + viewportWidthMax?: number, + sampling?: number, + since: string, + until: string, +}; 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/dev-tool.tsx b/packages/stack-shared/src/utils/dev-tool.tsx new file mode 100644 index 0000000000..3af8b747ec --- /dev/null +++ b/packages/stack-shared/src/utils/dev-tool.tsx @@ -0,0 +1,22 @@ +/** + * 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 + * 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 clickmaps 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..969f2400b7 --- /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 clickmap 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 ba135e8c8e..b4551ab29d 100644 --- a/packages/template/src/dev-tool/dev-tool-core.ts +++ b/packages/template/src/dev-tool/dev-tool-core.ts @@ -1,8 +1,18 @@ // IF_PLATFORM js-like import type { RequestLogEntry } from "@stackframe/stack-shared/dist/interface/client-interface"; +import { + 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 { 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"; +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"; @@ -52,7 +62,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; @@ -68,6 +78,11 @@ const TABS: { id: TabId; label: string; icon: string }[] = [ { id: 'support', label: 'Support', 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', @@ -663,8 +678,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 +738,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 +918,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 +966,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 +1794,1390 @@ function createAITab(app: StackClientApp): HTMLElement { return container; } +// --------------------------------------------------------------------------- +// Clickmaps tab +// --------------------------------------------------------------------------- + +type DevToolClickGroup = { + selector: string; + label: string; + count: number; + element: Element | null; + rect: DOMRect | null; +}; + +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'; + +type ClickmapRangeKey = '24h' | '7d' | '30d'; +type ClickmapDeviceKey = 'all' | 'mobile' | 'tablet' | 'laptop' | 'desktop' | 'widescreen' | 'tv'; + +type ClickmapFilters = { + range: ClickmapRangeKey, + device: ClickmapDeviceKey, + urlPattern: string, + elementSearch: string, +}; + +type ClickmapViewportBucket = { + min: number, + max: number | null, +}; + +const CLICKMAP_DEFAULT_FILTERS: ClickmapFilters = { + range: '7d', + device: 'all', + urlPattern: '', + elementSearch: '', +}; + +const CLICKMAP_RANGE_MS: Record = { + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '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'; +} +const CLICKMAP_DOM_INDEX_DEBOUNCE_MS = 250; + +type DevToolServerClickmapSelector = { + selector: string; + clicks: number; +}; + +type DevToolServerClickmapElement = { + elementsChain: string; + elementsText: string; + tagName: string; + href: string | null; + clicks: number; +}; + +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: DevToolServerClickmapSelector[]; + elements: DevToolServerClickmapElement[]; +}; + +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 formatClickmapCount(value: number): string { + if (value >= 1000) return `${Math.round(value / 100) / 10}k`; + return String(value); +} + +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); +} + + +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 isElementVisibleForClickmap(element: Element): boolean { + if (element.closest(`#${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(isElementVisibleForClickmap) ?? null; + } catch { + return null; + } +} + +function getSessionStorageString(key: string): string | null { + try { + const value = sessionStorage.getItem(key); + return value == null || value.trim() === '' ? null : value; + } catch { + return null; + } +} + +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. + } +} + +// 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; + } + 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 value = Reflect.get(payload, claim); + return typeof value === 'string' ? value : null; + } catch { + return null; + } +} + +function getClickmapTokenFromStorage(): string | null { + return getSessionStorageString(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY); +} + +function getClickmapOriginFromStorage(): string | null { + const token = getClickmapTokenFromStorage(); + return token == null ? null : getJwtPayloadClaim(token, 'origin'); +} + +function clearClickmapTokenStorage(): void { + removeSessionStorageItem(CLICKMAP_OVERLAY_TOKEN_STORAGE_KEY); +} + +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 = AnalyticsClickmapResponseBodySchema.validateSync(value); + } catch { + return { path, totalClicks: 0, 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, +// numeric id, Mongo ObjectId, ULID, etc.) rather than a human-readable slug? +// 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; + 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 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 { + const trailingSlash = pathname.length > 1 && pathname.endsWith('/'); + const segments = pathname.split('/').map((segment) => (isDynamicPathSegment(segment) ? '*' : segment)); + const joined = segments.join('/'); + return trailingSlash ? `${joined}/` : joined; +} + +// 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('.*'); +} + +// 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 matching mirrors the backend's +// anchored `LIKE`. +function patternMatchesPath(pattern: string, path: string): boolean { + if (pattern === '') return true; + try { + return new RegExp(`^${globToRegexSource(pattern)}$`).test(path); + } catch { + return false; + } +} + +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'); + 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 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 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'); + + function readStoredFilters(): ClickmapFilters { + try { + 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 { ...CLICKMAP_DEFAULT_FILTERS }; + const obj = parsed as Record; + return { + 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, + elementSearch: typeof obj.elementSearch === 'string' ? obj.elementSearch : CLICKMAP_DEFAULT_FILTERS.elementSearch, + }; + } catch { + return { ...CLICKMAP_DEFAULT_FILTERS }; + } + } + function persistFilters(next: ClickmapFilters) { + try { + sessionStorage.setItem(CLICKMAP_FILTERS_STORAGE_KEY, JSON.stringify(next)); + } catch { + // ignore storage errors + } + } + + let currentPath = window.location.pathname; + 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'; + 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; + } + + 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; + let domIndexDebounce = 0; + function rebuildDomIndex() { + domIndex.clear(); + const all = document.querySelectorAll('*'); + for (const el of all) { + if (!isElementVisibleForClickmap(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(); + }, CLICKMAP_DOM_INDEX_DEBOUNCE_MS); + } + + function isElementChainCandidateUnique(matches: Element[]): Element | null { + const visible = matches.filter(isElementVisibleForClickmap); + 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(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), + 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: 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 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 { + 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); + // 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 + //