mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Add route analytics heatmaps
This commit is contained in:
parent
ba947ecc0d
commit
23426ce2ff
@ -2,6 +2,12 @@
|
||||
|
||||
This file contains knowledge learned while working on the codebase in Q&A format.
|
||||
|
||||
## Q: How should the analytics heatmaps canvas avoid replay dimension coupling?
|
||||
A: In `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx`, keep the route heatmap canvas normalized to the backend `x_percent`/`y_percent` coordinates rather than rendering an rrweb replay frame behind it. Replay frames can recursively capture the dashboard and make aggregate clicks look tied to one browser size; use a neutral 100% x 100% grid for aggregate density, and leave exact pixel playback to the Session Replays player.
|
||||
|
||||
## Q: How should compact action controls in the analytics heatmaps sidebar header be styled?
|
||||
A: In `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/heatmaps/page-client.tsx`, the left sidebar header can get cramped at narrow panel widths. Keep the "Heatmaps (n)" title as `min-w-0 truncate` and use icon-only `DesignButton` controls wrapped in `SimpleTooltip` for clear/refresh actions, with explicit `aria-label`s.
|
||||
|
||||
## Q: What are the local development ports for the MCP and Skills apps?
|
||||
A: The MCP app runs on port suffix `44` from `apps/mcp/package.json`, so with `NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX=91` it is at `http://localhost:9144/mcp`. The Skills app runs on suffix `45` from `apps/skills/package.json`, so with the same prefix it is at `http://localhost:9145`. The dev launchpad app list in `apps/dev-launchpad/public/index.html` should use these suffixes.
|
||||
|
||||
@ -562,3 +568,27 @@ A: Project config overrides only support the hosted `sourceOfTruth` shape. Legac
|
||||
|
||||
## Q: How should managed email onboarding e2e tests wait for mock verification?
|
||||
A: Do not rely on a fixed `wait(1500)` after setup. The mock onboarding path flips the domain to `verified` asynchronously through `runAsynchronously`, so tests should poll the managed-onboarding check endpoint until the expected status appears.
|
||||
|
||||
## Q: How should route heatmap device filtering work?
|
||||
A: Route heatmaps are session replay click-density aggregates. Device filters should be applied in the curated ClickHouse heatmap endpoint, not only in the dashboard, so routes, points, linked users, linked recordings, and selectors all describe the same slice. Use `$click.data.viewport_width` to classify clicks into `tv`, `widescreen`, `desktop`, `laptop`, `tablet`, and `mobile`; keep the dashboard default as all devices.
|
||||
|
||||
## Q: What layout should the analytics route heatmaps page use?
|
||||
A: Use a two-column workbench layout: a left accordion stack and a dominant right-side route click-density heatmap panel. Routes, Device, Users, Recordings, and Player should all be accordion sections open by default; route search belongs inside the Routes section and should filter the backend aggregate by regex so `/projects` covers matching subroutes. Do not include separate card headers like "Controls" or "Click density".
|
||||
|
||||
## Q: How should route heatmap filters bind nullable ClickHouse columns?
|
||||
A: Bind selected heatmap `user_id` and `session_replay_id` filters as `Nullable(String)` parameters, matching the `analytics_internal.events` schema. Also avoid selecting aggregate aliases with the same names as filtered columns, e.g. use `any(user_id) AS linked_user_id` instead of `AS user_id`; ClickHouse can resolve `WHERE user_id = ...` against the aggregate alias and throw `ILLEGAL_AGGREGATION`, which surfaces as the generic 503 heatmap-unavailable fallback.
|
||||
|
||||
## Q: How should the analytics heatmaps page borrow PostHog-style heatmap behavior?
|
||||
A: Keep the route-first workbench, but mirror PostHog's split between heatmap density and clickmap element rollups: render click density over either an rrweb replay background or the normalized grid, and populate a `Clickmaps` accordion from `$click.data.selector` counts. The backend selector query should reuse the same route/user/replay/device filters as the point query so all side panels describe the same slice.
|
||||
|
||||
## Q: How should heatmap blobs align with an rrweb replay background?
|
||||
A: When the analytics heatmap uses an rrweb replay background, do not position heat blobs against the outer card. The replay is scaled and centered inside that card, so track the rendered rrweb wrapper rectangle after scaling and render the heatmap layer inside that rectangle; keep the normalized grid fallback using the full 100% canvas.
|
||||
|
||||
## Q: What should happen to the selected replay when the route heatmap device filter changes?
|
||||
A: Clear `selectedReplayId` when the device filter changes. The backend refetches routes, points, users, replays, and selectors for the new device slice, and the replay background should then choose the newest replay from that filtered slice instead of staying pinned to a previously selected desktop/tablet/mobile recording.
|
||||
|
||||
## Q: How should the dashboard avoid stale heatmap responses after rapid filter changes?
|
||||
A: Guard the analytics heatmap loader with a monotonically increasing generation ref. Device/filter changes can leave older all-devices requests in flight; if those resolve after the newer mobile/tablet/desktop request, ignore them so they cannot overwrite the current filtered replay background and counts.
|
||||
|
||||
## Q: How should analytics heatmap route rows show compact route stats?
|
||||
A: Keep the route path as the primary sidebar text, truncate it in the row, and show three compact icon counters for clicks, users, and recordings. Use a modest delayed tooltip on each route row to reveal the full route path and label each counter, so users can skim the list without instant hover popups while still understanding the icons.
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHourOfWeekHeatmapCells,
|
||||
getSessionReplayHeatmapDeviceFilter,
|
||||
getSessionReplayHeatmapReplayFilter,
|
||||
getSessionReplayHeatmapRouteFilter,
|
||||
getSessionReplayHeatmapUserFilter,
|
||||
} from "./route";
|
||||
|
||||
describe("analytics heatmap helpers", () => {
|
||||
it("pads sparse hour-of-week rows into a complete 7x24 grid", () => {
|
||||
const cells = buildHourOfWeekHeatmapCells([
|
||||
{ weekday: "1", hour: "0", value: "3" },
|
||||
{ weekday: 7, hour: 23, value: 9 },
|
||||
]);
|
||||
|
||||
expect(cells).toHaveLength(168);
|
||||
expect(cells[0]).toMatchInlineSnapshot(`
|
||||
{
|
||||
"hour": 0,
|
||||
"value": 3,
|
||||
"weekday": 1,
|
||||
}
|
||||
`);
|
||||
expect(cells[167]).toMatchInlineSnapshot(`
|
||||
{
|
||||
"hour": 23,
|
||||
"value": 9,
|
||||
"weekday": 7,
|
||||
}
|
||||
`);
|
||||
expect(cells[1]).toMatchInlineSnapshot(`
|
||||
{
|
||||
"hour": 1,
|
||||
"value": 0,
|
||||
"weekday": 1,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("ignores invalid ClickHouse bucket rows", () => {
|
||||
const cells = buildHourOfWeekHeatmapCells([
|
||||
{ weekday: 0, hour: 12, value: 10 },
|
||||
{ weekday: 1, hour: 24, value: 10 },
|
||||
{ weekday: 2, hour: 3, value: 4 },
|
||||
]);
|
||||
|
||||
expect(cells.find((cell) => cell.weekday === 2 && cell.hour === 3)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"hour": 3,
|
||||
"value": 4,
|
||||
"weekday": 2,
|
||||
}
|
||||
`);
|
||||
expect(cells.filter((cell) => cell.value !== 0)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("omits the device filter when every device is selected", () => {
|
||||
expect(getSessionReplayHeatmapDeviceFilter(undefined)).toMatchInlineSnapshot(`""`);
|
||||
});
|
||||
|
||||
it("builds the session replay viewport device filter", () => {
|
||||
expect(getSessionReplayHeatmapDeviceFilter("mobile")).toMatchInlineSnapshot(`
|
||||
"AND multiIf(
|
||||
toFloat64OrZero(toString(data.viewport_width)) >= 1920, 'tv',
|
||||
toFloat64OrZero(toString(data.viewport_width)) >= 1440, 'widescreen',
|
||||
toFloat64OrZero(toString(data.viewport_width)) >= 1200, 'desktop',
|
||||
toFloat64OrZero(toString(data.viewport_width)) >= 1024, 'laptop',
|
||||
toFloat64OrZero(toString(data.viewport_width)) >= 768, 'tablet',
|
||||
'mobile'
|
||||
) = {device:String}"
|
||||
`);
|
||||
});
|
||||
|
||||
it("prefers a route regex filter over an exact route filter", () => {
|
||||
expect(getSessionReplayHeatmapRouteFilter("/projects", "^/projects(/|$)")).toMatchInlineSnapshot(`"AND match(toString(data.path), {routeRegex:String})"`);
|
||||
});
|
||||
|
||||
it("falls back to exact route matching when no route regex is present", () => {
|
||||
expect(getSessionReplayHeatmapRouteFilter("/projects", undefined)).toMatchInlineSnapshot(`"AND toString(data.path) = {routePath:String}"`);
|
||||
});
|
||||
|
||||
it("binds the selected user filter as nullable to match the ClickHouse events schema", () => {
|
||||
expect(getSessionReplayHeatmapUserFilter("user-123")).toMatchInlineSnapshot(`"AND user_id = {userId:Nullable(String)}"`);
|
||||
});
|
||||
|
||||
it("omits the selected user filter when no user is selected", () => {
|
||||
expect(getSessionReplayHeatmapUserFilter(undefined)).toMatchInlineSnapshot(`""`);
|
||||
});
|
||||
|
||||
it("binds the selected replay filter as nullable to match the ClickHouse events schema", () => {
|
||||
expect(getSessionReplayHeatmapReplayFilter("replay-123")).toMatchInlineSnapshot(`"AND session_replay_id = {replayId:Nullable(String)}"`);
|
||||
});
|
||||
|
||||
it("omits the selected replay filter when no replay is selected", () => {
|
||||
expect(getSessionReplayHeatmapReplayFilter(undefined)).toMatchInlineSnapshot(`""`);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,368 @@
|
||||
import { getClickhouseAdminClientForMetrics } from "@/lib/clickhouse";
|
||||
import { getPrismaClientForTenancy } from "@/prisma-client";
|
||||
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
|
||||
import { AnalyticsHeatmapResponseBodySchema } from "@stackframe/stack-shared/dist/interface/admin-metrics";
|
||||
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { ClickHouseError } from "@clickhouse/client";
|
||||
import { HexclaveAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { userFullInclude, userPrismaToCrud } from "../../../users/crud";
|
||||
|
||||
const MAX_TEAM_MEMBER_IDS = 500;
|
||||
const MAX_WINDOW_DAYS = 92;
|
||||
const ROUTE_LIMIT = 50;
|
||||
const LINKED_LIMIT = 25;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function formatClickhouseDateTimeParam(date: Date): string {
|
||||
return date.toISOString().slice(0, 19);
|
||||
}
|
||||
|
||||
function parseBoundedDateTime(value: string, name: string): Date {
|
||||
const date = new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
throw new StatusError(StatusError.BadRequest, `Invalid ${name}`);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function getDeviceClassExpression(widthExpression: string): string {
|
||||
return `multiIf(
|
||||
${widthExpression} >= 1920, 'tv',
|
||||
${widthExpression} >= 1440, 'widescreen',
|
||||
${widthExpression} >= 1200, 'desktop',
|
||||
${widthExpression} >= 1024, 'laptop',
|
||||
${widthExpression} >= 768, 'tablet',
|
||||
'mobile'
|
||||
)`;
|
||||
}
|
||||
|
||||
export function getSessionReplayHeatmapDeviceFilter(device: string | undefined): string {
|
||||
if (device == null) {
|
||||
return "";
|
||||
}
|
||||
return `AND ${getDeviceClassExpression("toFloat64OrZero(toString(data.viewport_width))")} = {device:String}`;
|
||||
}
|
||||
|
||||
export function getSessionReplayHeatmapRouteFilter(routePath: string | undefined, routeRegex: string | undefined): string {
|
||||
if (routeRegex != null && routeRegex !== "") {
|
||||
return "AND match(toString(data.path), {routeRegex:String})";
|
||||
}
|
||||
if (routePath != null && routePath !== "") {
|
||||
return "AND toString(data.path) = {routePath:String}";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getSessionReplayHeatmapUserFilter(userId: string | undefined): string {
|
||||
if (userId == null || userId === "") {
|
||||
return "";
|
||||
}
|
||||
return "AND user_id = {userId:Nullable(String)}";
|
||||
}
|
||||
|
||||
export function getSessionReplayHeatmapReplayFilter(replayId: string | undefined): string {
|
||||
if (replayId == null || replayId === "") {
|
||||
return "";
|
||||
}
|
||||
return "AND session_replay_id = {replayId:Nullable(String)}";
|
||||
}
|
||||
|
||||
export function buildHourOfWeekHeatmapCells(rows: { weekday: number | string, hour: number | string, value: number | string }[]) {
|
||||
const byCell = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const weekday = Number(row.weekday);
|
||||
const hour = Number(row.hour);
|
||||
if (!Number.isInteger(weekday) || weekday < 1 || weekday > 7) continue;
|
||||
if (!Number.isInteger(hour) || hour < 0 || hour > 23) continue;
|
||||
byCell.set(`${weekday}:${hour}`, Number(row.value));
|
||||
}
|
||||
|
||||
const cells: { weekday: number, hour: number, value: number }[] = [];
|
||||
for (let weekday = 1; weekday <= 7; weekday += 1) {
|
||||
for (let hour = 0; hour < 24; hour += 1) {
|
||||
cells.push({ weekday, hour, value: byCell.get(`${weekday}:${hour}`) ?? 0 });
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
export const POST = createSmartRouteHandler({
|
||||
metadata: { hidden: true },
|
||||
request: yupObject({
|
||||
auth: yupObject({
|
||||
type: adminAuthTypeSchema.defined(),
|
||||
tenancy: adaptSchema.defined(),
|
||||
}),
|
||||
body: yupObject({
|
||||
kind: yupString().oneOf(["team_user_hour_of_week", "session_replay_clicks"]).defined(),
|
||||
member_user_ids: yupArray(yupString().defined()).optional().default([]).max(MAX_TEAM_MEMBER_IDS),
|
||||
route_path: yupString().optional(),
|
||||
route_regex: yupString().optional(),
|
||||
user_id: yupString().optional(),
|
||||
replay_id: yupString().optional(),
|
||||
device: yupString().oneOf(["tv", "widescreen", "desktop", "laptop", "tablet", "mobile"]).optional(),
|
||||
since: yupString().defined(),
|
||||
until: yupString().defined(),
|
||||
}).defined(),
|
||||
}),
|
||||
response: yupObject({
|
||||
statusCode: yupNumber().oneOf([200]).defined(),
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: AnalyticsHeatmapResponseBodySchema,
|
||||
}),
|
||||
handler: async ({ auth, body }) => {
|
||||
const since = parseBoundedDateTime(body.since, "since");
|
||||
const until = parseBoundedDateTime(body.until, "until");
|
||||
if (until.getTime() <= since.getTime()) {
|
||||
throw new StatusError(StatusError.BadRequest, "until must be after since");
|
||||
}
|
||||
if (until.getTime() - since.getTime() > MAX_WINDOW_DAYS * ONE_DAY_MS) {
|
||||
throw new StatusError(StatusError.BadRequest, `Heatmap window cannot exceed ${MAX_WINDOW_DAYS} days`);
|
||||
}
|
||||
|
||||
const client = getClickhouseAdminClientForMetrics();
|
||||
|
||||
try {
|
||||
if (body.kind === "session_replay_clicks") {
|
||||
const routeFilter = getSessionReplayHeatmapRouteFilter(body.route_path, body.route_regex);
|
||||
const userFilter = getSessionReplayHeatmapUserFilter(body.user_id);
|
||||
const replayFilter = getSessionReplayHeatmapReplayFilter(body.replay_id);
|
||||
const deviceFilter = getSessionReplayHeatmapDeviceFilter(body.device);
|
||||
const params = {
|
||||
projectId: auth.tenancy.project.id,
|
||||
branchId: auth.tenancy.branchId,
|
||||
since: formatClickhouseDateTimeParam(since),
|
||||
until: formatClickhouseDateTimeParam(until),
|
||||
linkedLimit: LINKED_LIMIT,
|
||||
routeLimit: ROUTE_LIMIT,
|
||||
...(body.route_path ? { routePath: body.route_path } : {}),
|
||||
...(body.route_regex ? { routeRegex: body.route_regex } : {}),
|
||||
...(body.user_id ? { userId: body.user_id } : {}),
|
||||
...(body.replay_id ? { replayId: body.replay_id } : {}),
|
||||
...(body.device ? { device: body.device } : {}),
|
||||
};
|
||||
const [pointsResult, routesResult, usersResult, replaysResult, selectorsResult] = await Promise.all([
|
||||
client.query({
|
||||
query: `
|
||||
SELECT
|
||||
round(toFloat64OrZero(toString(data.x)) / nullIf(toFloat64OrZero(toString(data.viewport_width)), 0) * 100, 1) AS x_percent,
|
||||
round(toFloat64OrZero(toString(data.y)) / nullIf(toFloat64OrZero(toString(data.viewport_height)), 0) * 100, 1) AS y_percent,
|
||||
count() AS count
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$click'
|
||||
AND project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {until:DateTime}
|
||||
AND toFloat64OrNull(toString(data.x)) IS NOT NULL
|
||||
AND toFloat64OrNull(toString(data.y)) IS NOT NULL
|
||||
AND toFloat64OrNull(toString(data.viewport_width)) > 0
|
||||
AND toFloat64OrNull(toString(data.viewport_height)) > 0
|
||||
${routeFilter}
|
||||
${userFilter}
|
||||
${replayFilter}
|
||||
${deviceFilter}
|
||||
GROUP BY x_percent, y_percent
|
||||
`,
|
||||
query_params: params,
|
||||
format: "JSONEachRow",
|
||||
}),
|
||||
client.query({
|
||||
query: `
|
||||
SELECT
|
||||
toString(data.path) AS path,
|
||||
count() AS clicks,
|
||||
uniqExactIf(assumeNotNull(user_id), user_id IS NOT NULL) AS users,
|
||||
uniqExactIf(assumeNotNull(session_replay_id), session_replay_id IS NOT NULL) AS replays
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$click'
|
||||
AND project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {until:DateTime}
|
||||
AND toString(data.path) != ''
|
||||
${routeFilter}
|
||||
${userFilter}
|
||||
${replayFilter}
|
||||
${deviceFilter}
|
||||
GROUP BY path
|
||||
ORDER BY clicks DESC
|
||||
LIMIT {routeLimit:UInt32}
|
||||
`,
|
||||
query_params: params,
|
||||
format: "JSONEachRow",
|
||||
}),
|
||||
client.query({
|
||||
query: `
|
||||
SELECT
|
||||
assumeNotNull(user_id) AS id,
|
||||
count() AS clicks,
|
||||
uniqExactIf(assumeNotNull(session_replay_id), session_replay_id IS NOT NULL) AS replays,
|
||||
toUnixTimestamp64Milli(max(event_at)) AS last_event_at_millis
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$click'
|
||||
AND project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {until:DateTime}
|
||||
AND user_id IS NOT NULL
|
||||
${routeFilter}
|
||||
${replayFilter}
|
||||
${deviceFilter}
|
||||
GROUP BY id
|
||||
ORDER BY last_event_at_millis DESC, clicks DESC
|
||||
LIMIT {linkedLimit:UInt32}
|
||||
`,
|
||||
query_params: params,
|
||||
format: "JSONEachRow",
|
||||
}),
|
||||
client.query({
|
||||
query: `
|
||||
SELECT
|
||||
assumeNotNull(session_replay_id) AS id,
|
||||
any(user_id) AS linked_user_id,
|
||||
nullIf(any(toString(data.path)), '') AS route_path,
|
||||
toInt32OrNull(any(toString(data.viewport_width))) AS viewport_width,
|
||||
toInt32OrNull(any(toString(data.viewport_height))) AS viewport_height,
|
||||
count() AS clicks,
|
||||
toUnixTimestamp64Milli(max(event_at)) AS last_event_at_millis
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$click'
|
||||
AND project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {until:DateTime}
|
||||
AND session_replay_id IS NOT NULL
|
||||
${routeFilter}
|
||||
${userFilter}
|
||||
${deviceFilter}
|
||||
GROUP BY id
|
||||
ORDER BY clicks DESC
|
||||
LIMIT {linkedLimit:UInt32}
|
||||
`,
|
||||
query_params: params,
|
||||
format: "JSONEachRow",
|
||||
}),
|
||||
client.query({
|
||||
query: `
|
||||
SELECT
|
||||
nullIf(toString(data.selector), '') AS selector,
|
||||
count() AS clicks
|
||||
FROM analytics_internal.events
|
||||
WHERE event_type = '$click'
|
||||
AND project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {until:DateTime}
|
||||
AND nullIf(toString(data.selector), '') IS NOT NULL
|
||||
${routeFilter}
|
||||
${userFilter}
|
||||
${replayFilter}
|
||||
${deviceFilter}
|
||||
GROUP BY selector
|
||||
ORDER BY clicks DESC
|
||||
LIMIT {linkedLimit:UInt32}
|
||||
`,
|
||||
query_params: params,
|
||||
format: "JSONEachRow",
|
||||
}),
|
||||
]);
|
||||
const points: { x_percent: number | string, y_percent: number | string, count: number | string }[] = await pointsResult.json();
|
||||
const routes: { path: string, clicks: number | string, users: number | string, replays: number | string }[] = await routesResult.json();
|
||||
const users: { id: string, clicks: number | string, replays: number | string, last_event_at_millis: number | string }[] = await usersResult.json();
|
||||
const replays: { id: string, linked_user_id: string | null, route_path: string | null, viewport_width: number | string | null, viewport_height: number | string | null, clicks: number | string, last_event_at_millis: number | string }[] = await replaysResult.json();
|
||||
const selectors: { selector: string, clicks: number | string }[] = await selectorsResult.json();
|
||||
const userIds = users.map((row) => row.id);
|
||||
const prisma = await getPrismaClientForTenancy(auth.tenancy);
|
||||
const dbUsers = userIds.length === 0 ? [] : await prisma.$replica().projectUser.findMany({
|
||||
where: {
|
||||
tenancyId: auth.tenancy.id,
|
||||
projectUserId: { in: userIds },
|
||||
},
|
||||
include: userFullInclude,
|
||||
});
|
||||
const userProfilesById = new Map(dbUsers.map((user) => {
|
||||
const crud = userPrismaToCrud(user, auth.tenancy.config);
|
||||
return [crud.id, {
|
||||
display_name: crud.display_name,
|
||||
primary_email: crud.primary_email,
|
||||
profile_image_url: crud.profile_image_url,
|
||||
}];
|
||||
}));
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
bodyType: "json",
|
||||
body: {
|
||||
kind: body.kind,
|
||||
cells: [],
|
||||
points: points.map((row) => ({ x_percent: Number(row.x_percent), y_percent: Number(row.y_percent), count: Number(row.count) })),
|
||||
routes: routes.map((row) => ({ path: row.path, clicks: Number(row.clicks), users: Number(row.users), replays: Number(row.replays) })),
|
||||
users: users.map((row) => {
|
||||
const profile = userProfilesById.get(row.id);
|
||||
return {
|
||||
id: row.id,
|
||||
display_name: profile?.display_name ?? null,
|
||||
primary_email: profile?.primary_email ?? null,
|
||||
profile_image_url: profile?.profile_image_url ?? null,
|
||||
clicks: Number(row.clicks),
|
||||
replays: Number(row.replays),
|
||||
last_event_at_millis: Number(row.last_event_at_millis),
|
||||
};
|
||||
}),
|
||||
replays: replays.map((row) => ({
|
||||
id: row.id,
|
||||
user_id: row.linked_user_id,
|
||||
route_path: row.route_path,
|
||||
viewport_width: row.viewport_width == null ? null : Number(row.viewport_width),
|
||||
viewport_height: row.viewport_height == null ? null : Number(row.viewport_height),
|
||||
clicks: Number(row.clicks),
|
||||
last_event_at_millis: Number(row.last_event_at_millis),
|
||||
})),
|
||||
selectors: selectors.map((row) => ({ selector: row.selector, clicks: Number(row.clicks) })),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (body.member_user_ids.length === 0) {
|
||||
return { statusCode: 200, bodyType: "json", body: { kind: body.kind, cells: buildHourOfWeekHeatmapCells([]), points: [], routes: [], users: [], replays: [], selectors: [] } };
|
||||
}
|
||||
|
||||
const result = await client.query({
|
||||
query: `
|
||||
SELECT toDayOfWeek(event_at) AS weekday, toHour(event_at) AS hour, uniqExact(assumeNotNull(user_id)) AS value
|
||||
FROM analytics_internal.events
|
||||
WHERE project_id = {projectId:String}
|
||||
AND branch_id = {branchId:String}
|
||||
AND user_id IN {memberUserIds:Array(String)}
|
||||
AND event_at >= {since:DateTime}
|
||||
AND event_at < {until:DateTime}
|
||||
GROUP BY weekday, hour
|
||||
ORDER BY weekday ASC, hour ASC
|
||||
`,
|
||||
query_params: {
|
||||
projectId: auth.tenancy.project.id,
|
||||
branchId: auth.tenancy.branchId,
|
||||
memberUserIds: body.member_user_ids,
|
||||
since: formatClickhouseDateTimeParam(since),
|
||||
until: formatClickhouseDateTimeParam(until),
|
||||
},
|
||||
format: "JSONEachRow",
|
||||
});
|
||||
const rows: { weekday: number | string, hour: number | string, value: number | string }[] = await result.json();
|
||||
return { statusCode: 200, bodyType: "json", body: { kind: body.kind, cells: buildHourOfWeekHeatmapCells(rows), points: [], routes: [], users: [], replays: [], selectors: [] } };
|
||||
} catch (error) {
|
||||
if (!(error instanceof ClickHouseError)) {
|
||||
throw error;
|
||||
}
|
||||
if (body.kind === "session_replay_clicks" && body.route_regex != null && body.route_regex !== "") {
|
||||
throw new StatusError(StatusError.BadRequest, "Invalid route regex");
|
||||
}
|
||||
captureError("internal-analytics-heatmap-clickhouse-fallback", new HexclaveAssertionError(
|
||||
"Failed to load analytics heatmap due to ClickHouse query failure.",
|
||||
{ cause: error, projectId: auth.tenancy.project.id, branchId: auth.tenancy.branchId, kind: body.kind },
|
||||
));
|
||||
throw new StatusError(StatusError.ServiceUnavailable, "Analytics heatmap is temporarily unavailable.");
|
||||
}
|
||||
},
|
||||
});
|
||||
@ -105,6 +105,7 @@ export function handleApiRequest(handler: (req: NextRequest, options: any, reque
|
||||
// request duration warning
|
||||
const allowedLongRequestPaths = [
|
||||
"/api/latest/internal/email-queue-step",
|
||||
"/api/latest/internal/analytics/heatmap",
|
||||
"/api/latest/internal/analytics/query",
|
||||
"/api/latest/ai/query/stream",
|
||||
"/api/latest/ai/query/generate",
|
||||
|
||||
@ -0,0 +1,688 @@
|
||||
"use client";
|
||||
|
||||
import { DesignBadge, DesignButton, DesignInput, DesignPillToggle } from "@/components/design-components";
|
||||
import { StyledLink } from "@/components/link";
|
||||
import { Avatar, AvatarFallback, AvatarImage, Skeleton } from "@/components/ui";
|
||||
import { SimpleTooltip } from "@/components/ui/simple-tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowClockwiseIcon, CaretDownIcon, CursorClickIcon, DesktopIcon, DeviceMobileIcon, DeviceTabletIcon, DevicesIcon, GridFourIcon, MonitorIcon, MonitorPlayIcon, PathIcon, PlayIcon, UserCircleIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics";
|
||||
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { AppEnabledGuard } from "../../app-enabled-guard";
|
||||
import { PageLayout } from "../../page-layout";
|
||||
import { useAdminApp } from "../../use-admin-app";
|
||||
import { AnalyticsEventLimitBanner } from "../shared";
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const ALL_DEVICES_VALUE = "all";
|
||||
const ROUTE_ROW_TOOLTIP_DELAY_MS = 420;
|
||||
|
||||
type DeviceFilter = AnalyticsHeatmapDevice | typeof ALL_DEVICES_VALUE;
|
||||
type BackgroundMode = "grid" | "replay";
|
||||
type RrwebEventWithTime = import("rrweb/typings/types").eventWithTime;
|
||||
type RrwebReplayer = InstanceType<typeof import("rrweb").Replayer>;
|
||||
type HeatmapFrameRect = {
|
||||
left: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number,
|
||||
};
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "error", message: string }
|
||||
| { status: "ready", data: AnalyticsHeatmapResponse };
|
||||
|
||||
function formatCompact(n: number) {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 10_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
function parseDeviceFilter(value: string): DeviceFilter {
|
||||
switch (value) {
|
||||
case ALL_DEVICES_VALUE:
|
||||
case "tv":
|
||||
case "widescreen":
|
||||
case "desktop":
|
||||
case "laptop":
|
||||
case "tablet":
|
||||
case "mobile": {
|
||||
return value;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown heatmap device filter "${value}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegexLiteral(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getUserLabel(user: AnalyticsHeatmapResponse["users"][number]) {
|
||||
return user.display_name ?? user.primary_email ?? user.id;
|
||||
}
|
||||
|
||||
function getUserInitials(user: AnalyticsHeatmapResponse["users"][number]) {
|
||||
return getUserLabel(user).slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function isRrwebEventWithTime(value: unknown): value is RrwebEventWithTime {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
const timestamp = Reflect.get(value, "timestamp");
|
||||
const type = Reflect.get(value, "type");
|
||||
return typeof timestamp === "number" && Number.isFinite(timestamp) && (typeof type === "number" || typeof type === "string");
|
||||
}
|
||||
|
||||
function getReferenceReplayId(replays: AnalyticsHeatmapResponse["replays"], selectedReplayId: string | null) {
|
||||
if (selectedReplayId != null) {
|
||||
return selectedReplayId;
|
||||
}
|
||||
return replays
|
||||
.slice()
|
||||
.sort((a, b) => b.last_event_at_millis - a.last_event_at_millis)[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export default function PageClient() {
|
||||
const adminApp = useAdminApp();
|
||||
const project = adminApp.useProject();
|
||||
const [routeRegex, setRouteRegex] = useState("");
|
||||
const [selectedReplayId, setSelectedReplayId] = useState<string | null>(null);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [deviceFilter, setDeviceFilter] = useState<DeviceFilter>(ALL_DEVICES_VALUE);
|
||||
const [backgroundMode, setBackgroundMode] = useState<BackgroundMode>("replay");
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
const loadGenerationRef = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const generation = loadGenerationRef.current + 1;
|
||||
loadGenerationRef.current = generation;
|
||||
setState({ status: "loading" });
|
||||
try {
|
||||
const data = await adminApp.getAnalyticsHeatmap({
|
||||
kind: "session_replay_clicks",
|
||||
route_regex: routeRegex.trim() || undefined,
|
||||
replay_id: selectedReplayId ?? undefined,
|
||||
user_id: selectedUserId ?? undefined,
|
||||
device: deviceFilter === ALL_DEVICES_VALUE ? undefined : deviceFilter,
|
||||
since: new Date(Date.now() - 28 * ONE_DAY_MS).toISOString(),
|
||||
until: new Date().toISOString(),
|
||||
});
|
||||
if (loadGenerationRef.current !== generation) {
|
||||
return;
|
||||
}
|
||||
setState({ status: "ready", data });
|
||||
} catch (error) {
|
||||
if (loadGenerationRef.current !== generation) {
|
||||
return;
|
||||
}
|
||||
setState({ status: "error", message: error instanceof Error ? error.message : "Failed to load route heatmap" });
|
||||
}
|
||||
}, [adminApp, routeRegex, selectedReplayId, selectedUserId, deviceFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
runAsynchronously(load);
|
||||
}, [load]);
|
||||
|
||||
const data = state.status === "ready" ? state.data : null;
|
||||
const maxPoint = Math.max(0, ...(data?.points.map((point) => point.count) ?? []));
|
||||
const routes = useMemo(() => data?.routes ?? [], [data?.routes]);
|
||||
const referenceReplayId = data == null ? null : getReferenceReplayId(data.replays, selectedReplayId);
|
||||
const hasActiveFilters = routeRegex.trim() !== "" || deviceFilter !== ALL_DEVICES_VALUE || selectedReplayId != null || selectedUserId != null;
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setRouteRegex("");
|
||||
setSelectedReplayId(null);
|
||||
setSelectedUserId(null);
|
||||
setDeviceFilter(ALL_DEVICES_VALUE);
|
||||
}, []);
|
||||
|
||||
const selectDeviceFilter = useCallback((value: string) => {
|
||||
setDeviceFilter(parseDeviceFilter(value));
|
||||
setSelectedReplayId(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Route heatmaps"
|
||||
description="Session replay click density grouped by route."
|
||||
fillWidth
|
||||
>
|
||||
<AppEnabledGuard appId="analytics">
|
||||
<AnalyticsEventLimitBanner />
|
||||
<PanelGroup direction="horizontal" className="min-h-[760px] flex-1 overflow-hidden rounded-xl border border-border/40 bg-background">
|
||||
<Panel defaultSize={28} minSize={20} maxSize={42}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-10 shrink-0 items-center justify-between gap-2 border-b border-border/30 px-3">
|
||||
<div className="min-w-0 truncate text-sm font-medium">
|
||||
Heatmaps{!state.status.startsWith("loading") && routes.length > 0 ? ` (${routes.length})` : ""}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<SimpleTooltip tooltip="Clear filters" disabled={!hasActiveFilters}>
|
||||
<DesignButton size="sm" variant="secondary" onClick={clearFilters} disabled={!hasActiveFilters} aria-label="Clear filters" className="h-7 w-7 p-0">
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</DesignButton>
|
||||
</SimpleTooltip>
|
||||
<SimpleTooltip tooltip="Refresh heatmaps">
|
||||
<DesignButton size="sm" variant="secondary" onClick={load} disabled={state.status === "loading"} aria-label="Refresh heatmaps" className="h-7 w-7 p-0">
|
||||
<ArrowClockwiseIcon className="h-3.5 w-3.5" />
|
||||
</DesignButton>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-foreground/[0.08]">
|
||||
<HeatmapAccordionSection title="Routes" icon={<PathIcon className="h-3.5 w-3.5" />}>
|
||||
<div className="flex max-h-[380px] flex-col gap-3 overflow-auto pb-3 pr-1">
|
||||
<DesignInput value={routeRegex} onChange={(e) => setRouteRegex(e.target.value)} placeholder="Filter routes or regex, e.g. /projects or ^/projects/.*" className="h-8 text-xs" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRouteRegex("");
|
||||
setSelectedReplayId(null);
|
||||
setSelectedUserId(null);
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2 text-left transition-colors duration-150 hover:transition-none",
|
||||
routeRegex.trim() === "" ? "bg-cyan-500/10 ring-1 ring-cyan-500/20" : "hover:bg-foreground/[0.04]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">All routes</span>
|
||||
<DesignBadge label={formatCompact(routes.reduce((sum, route) => sum + route.clicks, 0))} color="cyan" size="sm" />
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-muted-foreground">Combined click heatmap</div>
|
||||
</button>
|
||||
{state.status === "loading" ? (
|
||||
<div className="flex flex-col gap-2">{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-14 rounded-lg" />)}</div>
|
||||
) : routes.length === 0 ? (
|
||||
<EmptyPanel label="No routes have replay clicks in this slice." />
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{routes.map((route) => (
|
||||
<RouteListItem
|
||||
key={route.path}
|
||||
route={route}
|
||||
isSelected={routeRegex.trim() === `^${escapeRegexLiteral(route.path)}$`}
|
||||
onSelect={() => {
|
||||
setRouteRegex(`^${escapeRegexLiteral(route.path)}$`);
|
||||
setSelectedReplayId(null);
|
||||
setSelectedUserId(null);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HeatmapAccordionSection>
|
||||
|
||||
<HeatmapAccordionSection title="Device" icon={<DevicesIcon className="h-3.5 w-3.5" />}>
|
||||
<div className="pb-3">
|
||||
<DesignPillToggle
|
||||
selected={deviceFilter}
|
||||
onSelect={selectDeviceFilter}
|
||||
options={[
|
||||
{ id: ALL_DEVICES_VALUE, label: "All devices", icon: DevicesIcon },
|
||||
{ id: "tv", label: "TV", icon: MonitorIcon },
|
||||
{ id: "widescreen", label: "Widescreen", icon: MonitorIcon },
|
||||
{ id: "desktop", label: "Desktop", icon: DesktopIcon },
|
||||
{ id: "laptop", label: "Laptop", icon: DesktopIcon },
|
||||
{ id: "tablet", label: "Tablet", icon: DeviceTabletIcon },
|
||||
{ id: "mobile", label: "Mobile", icon: DeviceMobileIcon },
|
||||
]}
|
||||
size="sm"
|
||||
gradient="cyan"
|
||||
showLabels={false}
|
||||
className="flex w-full justify-between"
|
||||
/>
|
||||
</div>
|
||||
</HeatmapAccordionSection>
|
||||
|
||||
<HeatmapAccordionSection title="Clickmaps" icon={<CursorClickIcon className="h-3.5 w-3.5" />}>
|
||||
<div className="flex max-h-64 flex-col gap-1 overflow-auto pb-3 pr-1">
|
||||
{(data?.selectors ?? []).length === 0 ? <EmptyPanel label="No captured selectors in this slice." /> : data?.selectors.map((selector, index) => (
|
||||
<div
|
||||
key={selector.selector}
|
||||
className="rounded-lg px-3 py-2 transition-colors duration-150 hover:transition-none hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono text-xs">{index + 1}. {selector.selector}</span>
|
||||
<DesignBadge label={formatCompact(selector.clicks)} color="cyan" size="sm" />
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] text-muted-foreground">Autocaptured element clicks</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</HeatmapAccordionSection>
|
||||
|
||||
<HeatmapAccordionSection title="Users" icon={<UserCircleIcon className="h-3.5 w-3.5" />}>
|
||||
<div className="flex max-h-64 flex-col gap-1 overflow-auto pb-3 pr-1">
|
||||
{(data?.users ?? []).length === 0 ? <EmptyPanel label="No linked users." /> : data?.users.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedUserId(user.id);
|
||||
setSelectedReplayId(null);
|
||||
}}
|
||||
className={cn("rounded-lg px-3 py-2 text-left transition-colors duration-150 hover:transition-none", selectedUserId === user.id ? "bg-emerald-500/10 ring-1 ring-emerald-500/20" : "hover:bg-foreground/[0.04]")}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Avatar className="h-7 w-7 shrink-0">
|
||||
<AvatarImage src={user.profile_image_url ?? undefined} alt={getUserLabel(user)} />
|
||||
<AvatarFallback className="text-[10px]">{getUserInitials(user)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs font-medium">{getUserLabel(user)}</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">{formatCompact(user.clicks)} clicks · {formatCompact(user.replays)} replays</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</HeatmapAccordionSection>
|
||||
|
||||
<HeatmapAccordionSection title="Recordings" icon={<MonitorPlayIcon className="h-3.5 w-3.5" />}>
|
||||
<div className="flex max-h-64 flex-col gap-1 overflow-auto pb-3 pr-1">
|
||||
{(data?.replays ?? []).length === 0 ? <EmptyPanel label="No linked recordings." /> : data?.replays.map((replay) => (
|
||||
<button
|
||||
key={replay.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedReplayId(replay.id);
|
||||
setSelectedUserId(null);
|
||||
}}
|
||||
className={cn("rounded-lg px-3 py-2 text-left transition-colors duration-150 hover:transition-none", selectedReplayId === replay.id ? "bg-cyan-500/10 ring-1 ring-cyan-500/20" : "hover:bg-foreground/[0.04]")}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-mono text-xs">{replay.id}</span>
|
||||
<DesignBadge label={formatCompact(replay.clicks)} color="cyan" size="sm" />
|
||||
</div>
|
||||
<div className="mt-1 truncate text-[10px] text-muted-foreground">
|
||||
{replay.viewport_width ?? "?"}x{replay.viewport_height ?? "?"}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</HeatmapAccordionSection>
|
||||
|
||||
<HeatmapAccordionSection title="Player" icon={<PlayIcon className="h-3.5 w-3.5" />}>
|
||||
<div className="pb-3">
|
||||
{selectedReplayId ? (
|
||||
<StyledLink href={`/projects/${project.id}/session-replays/${selectedReplayId}`} className="inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium text-cyan-600 transition-colors duration-150 hover:transition-none hover:bg-cyan-500/10">
|
||||
<PlayIcon className="h-3.5 w-3.5" />
|
||||
Open replay player
|
||||
</StyledLink>
|
||||
) : (
|
||||
<EmptyPanel label="Select a replay to open the player." />
|
||||
)}
|
||||
</div>
|
||||
</HeatmapAccordionSection>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<PanelResizeHandle className="w-px bg-border/40 transition-colors hover:transition-none hover:bg-border" />
|
||||
|
||||
<Panel defaultSize={72} minSize={45}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-10 shrink-0 items-center justify-between gap-3 border-b border-border/30 px-3 py-2">
|
||||
<div className="truncate text-sm font-medium">
|
||||
{routeRegex.trim() || "All routes"}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<DesignPillToggle
|
||||
selected={backgroundMode}
|
||||
onSelect={(value) => setBackgroundMode(value === "grid" ? "grid" : "replay")}
|
||||
options={[
|
||||
{ id: "replay", label: "Replay", icon: MonitorPlayIcon },
|
||||
{ id: "grid", label: "Grid", icon: GridFourIcon },
|
||||
]}
|
||||
size="sm"
|
||||
gradient="cyan"
|
||||
showLabels
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{deviceFilter === ALL_DEVICES_VALUE ? "All devices" : deviceFilter}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{state.status === "error" ? (
|
||||
<EmptyPanel label={state.message} />
|
||||
) : state.status === "loading" ? (
|
||||
<Skeleton className="h-full min-h-[620px] rounded-2xl" />
|
||||
) : (
|
||||
<ReplayHeatmap
|
||||
adminApp={adminApp}
|
||||
points={state.data.points}
|
||||
max={maxPoint}
|
||||
selectedReplayId={selectedReplayId}
|
||||
backgroundMode={backgroundMode}
|
||||
referenceReplayId={referenceReplayId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</AppEnabledGuard>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteListItem({ route, isSelected, onSelect }: {
|
||||
route: AnalyticsHeatmapResponse["routes"][number],
|
||||
isSelected: boolean,
|
||||
onSelect: () => void,
|
||||
}) {
|
||||
const metrics = [
|
||||
{ label: "Clicks", value: route.clicks, icon: CursorClickIcon },
|
||||
{ label: "Users", value: route.users, icon: UserCircleIcon },
|
||||
{ label: "Recordings", value: route.replays, icon: MonitorPlayIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={ROUTE_ROW_TOOLTIP_DELAY_MS} disableHoverableContent={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
aria-label={`Select route ${route.path}`}
|
||||
className={cn(
|
||||
"grid h-10 w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-lg px-3 text-left transition-colors duration-150 hover:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/45",
|
||||
isSelected ? "bg-cyan-500/10 ring-1 ring-cyan-500/20" : "hover:bg-foreground/[0.04]",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate font-mono text-xs text-foreground">{route.path}</span>
|
||||
<span className="flex shrink-0 items-center gap-2 text-[10px] text-muted-foreground">
|
||||
{metrics.map((metric) => {
|
||||
const Icon = metric.icon;
|
||||
return (
|
||||
<span key={metric.label} className="inline-flex min-w-0 items-center gap-0.5 tabular-nums" aria-label={`${formatCompact(metric.value)} ${metric.label.toLowerCase()}`}>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span>{formatCompact(metric.value)}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="right" align="start" sideOffset={8} className="max-w-96 p-3 text-left">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] font-medium uppercase text-primary-foreground/70">Route</div>
|
||||
<div className="break-all font-mono text-xs">{route.path}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{metrics.map((metric) => {
|
||||
const Icon = metric.icon;
|
||||
return (
|
||||
<div key={metric.label} className="rounded-md bg-primary-foreground/10 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1 text-[10px] text-primary-foreground/70">
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||||
{metric.label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs font-medium tabular-nums">{formatCompact(metric.value)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function HeatmapAccordionSection({ title, icon, children }: { title: string, icon: ReactNode, children: ReactNode }) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<section className="px-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => setIsOpen((current) => !current)}
|
||||
className="flex w-full items-center justify-between py-3 text-xs font-medium transition-colors duration-150 hover:transition-none hover:text-foreground"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{icon}
|
||||
{title}
|
||||
</span>
|
||||
<CaretDownIcon className={cn("h-3.5 w-3.5 text-muted-foreground", isOpen ? "rotate-180" : "")} />
|
||||
</button>
|
||||
{isOpen ? children : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplayHeatmap({ adminApp, points, max, selectedReplayId, backgroundMode, referenceReplayId }: {
|
||||
adminApp: ReturnType<typeof useAdminApp>,
|
||||
points: AnalyticsHeatmapResponse["points"],
|
||||
max: number,
|
||||
selectedReplayId: string | null,
|
||||
backgroundMode: BackgroundMode,
|
||||
referenceReplayId: string | null,
|
||||
}) {
|
||||
const [servedFrameRect, setServedFrameRect] = useState<HeatmapFrameRect | null>(null);
|
||||
const heatmapFrameRect = backgroundMode === "replay" && servedFrameRect != null ? servedFrameRect : null;
|
||||
const heatmapSizeScale = heatmapFrameRect == null ? 1 : Math.max(0.55, Math.min(1.35, Math.min(heatmapFrameRect.width, heatmapFrameRect.height) / 700));
|
||||
|
||||
useEffect(() => {
|
||||
if (backgroundMode !== "replay") {
|
||||
setServedFrameRect(null);
|
||||
}
|
||||
}, [backgroundMode]);
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto aspect-[16/10] min-h-[620px] w-full overflow-hidden rounded-2xl bg-background ring-1 ring-foreground/[0.08]">
|
||||
{backgroundMode === "replay" && referenceReplayId != null ? (
|
||||
<ReplayBackgroundFrame adminApp={adminApp} replayId={referenceReplayId} onFrameRectChange={setServedFrameRect} />
|
||||
) : (
|
||||
<NormalizedHeatmapFrame selectedReplayId={selectedReplayId} />
|
||||
)}
|
||||
<div
|
||||
className="absolute pointer-events-none"
|
||||
style={heatmapFrameRect == null ? { inset: 0 } : {
|
||||
left: heatmapFrameRect.left,
|
||||
top: heatmapFrameRect.top,
|
||||
width: heatmapFrameRect.width,
|
||||
height: heatmapFrameRect.height,
|
||||
}}
|
||||
>
|
||||
{points.map((point, index) => {
|
||||
const intensity = max > 0 ? point.count / max : 0;
|
||||
const size = (16 + intensity * 76) * heatmapSizeScale;
|
||||
return (
|
||||
<span
|
||||
key={`${point.x_percent}:${point.y_percent}:${index}`}
|
||||
className="absolute rounded-full blur-lg"
|
||||
title={`${formatCompact(point.count)} clicks`}
|
||||
style={{
|
||||
left: `${point.x_percent}%`,
|
||||
top: `${point.y_percent}%`,
|
||||
width: size,
|
||||
height: size,
|
||||
transform: "translate(-50%, -50%)",
|
||||
backgroundColor: intensity > 0.66 ? "rgba(239, 68, 68, 0.45)" : intensity > 0.33 ? "rgba(245, 158, 11, 0.42)" : "rgba(6, 182, 212, 0.38)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplayBackgroundFrame({ adminApp, replayId, onFrameRectChange }: {
|
||||
adminApp: ReturnType<typeof useAdminApp>,
|
||||
replayId: string,
|
||||
onFrameRectChange: (rect: HeatmapFrameRect | null) => void,
|
||||
}) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const replayerRef = useRef<RrwebReplayer | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (root == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setStatus("loading");
|
||||
onFrameRectChange(null);
|
||||
root.replaceChildren();
|
||||
replayerRef.current = null;
|
||||
resizeObserverRef.current?.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
|
||||
runAsynchronously(async () => {
|
||||
const response = await adminApp.getSessionReplayEvents(replayId, { offset: 0, limit: 20 });
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const events = response.chunkEvents
|
||||
.flatMap((chunk) => chunk.events)
|
||||
.filter(isRrwebEventWithTime);
|
||||
|
||||
if (events.length === 0) {
|
||||
throw new Error("No replay snapshot events were available for the heatmap background.");
|
||||
}
|
||||
|
||||
const { Replayer } = await import("rrweb");
|
||||
|
||||
const replayer = new Replayer(events, {
|
||||
root,
|
||||
speed: 1,
|
||||
skipInactive: true,
|
||||
triggerFocus: false,
|
||||
mouseTail: false,
|
||||
});
|
||||
replayerRef.current = replayer;
|
||||
|
||||
root.style.position = "relative";
|
||||
root.style.width = "100%";
|
||||
root.style.height = "100%";
|
||||
root.style.overflow = "hidden";
|
||||
|
||||
replayer.wrapper.style.margin = "0";
|
||||
replayer.wrapper.style.position = "absolute";
|
||||
replayer.wrapper.style.transformOrigin = "top left";
|
||||
replayer.iframe.style.border = "0";
|
||||
replayer.iframe.style.pointerEvents = "none";
|
||||
|
||||
const updateScale = () => {
|
||||
const containerWidth = root.clientWidth;
|
||||
const containerHeight = root.clientHeight;
|
||||
const replayWidth = replayer.wrapper.offsetWidth;
|
||||
const replayHeight = replayer.wrapper.offsetHeight;
|
||||
if (containerWidth <= 0 || containerHeight <= 0 || replayWidth <= 0 || replayHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
const scale = Math.min(containerWidth / replayWidth, containerHeight / replayHeight);
|
||||
const scaledWidth = replayWidth * scale;
|
||||
const scaledHeight = replayHeight * scale;
|
||||
const left = (containerWidth - scaledWidth) / 2;
|
||||
const top = (containerHeight - scaledHeight) / 2;
|
||||
replayer.wrapper.style.left = `${left}px`;
|
||||
replayer.wrapper.style.top = `${top}px`;
|
||||
replayer.wrapper.style.transform = `scale(${scale})`;
|
||||
onFrameRectChange({ left, top, width: scaledWidth, height: scaledHeight });
|
||||
};
|
||||
|
||||
updateScale();
|
||||
let scaleRaf = 0;
|
||||
const observer = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(scaleRaf);
|
||||
scaleRaf = requestAnimationFrame(updateScale);
|
||||
});
|
||||
observer.observe(root);
|
||||
observer.observe(replayer.wrapper);
|
||||
resizeObserverRef.current = observer;
|
||||
|
||||
replayer.play(0);
|
||||
replayer.pause(0);
|
||||
setStatus("ready");
|
||||
}, {
|
||||
noErrorLogging: true,
|
||||
onError: () => {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
onFrameRectChange(null);
|
||||
resizeObserverRef.current?.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
replayerRef.current?.pause();
|
||||
replayerRef.current = null;
|
||||
root.replaceChildren();
|
||||
};
|
||||
}, [adminApp, onFrameRectChange, replayId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={rootRef} className={cn("absolute inset-0 bg-background", status === "ready" ? "opacity-70" : "opacity-0")} />
|
||||
{status !== "ready" ? <NormalizedHeatmapFrame selectedReplayId={replayId} /> : null}
|
||||
{status === "loading" ? (
|
||||
<div className="absolute right-6 top-5 z-20 rounded-xl bg-background/82 px-3 py-2 text-[10px] text-muted-foreground ring-1 ring-foreground/[0.06]">
|
||||
Loading replay background
|
||||
</div>
|
||||
) : null}
|
||||
{status === "error" ? (
|
||||
<div className="absolute right-6 top-5 z-20 rounded-xl bg-background/82 px-3 py-2 text-[10px] text-muted-foreground ring-1 ring-foreground/[0.06]">
|
||||
Replay background unavailable
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NormalizedHeatmapFrame({ selectedReplayId }: { selectedReplayId: string | null }) {
|
||||
return (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,rgba(148,163,184,0.09)_1px,transparent_1px),linear-gradient(to_bottom,rgba(148,163,184,0.09)_1px,transparent_1px)] bg-[size:8.333%_10%]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(6,182,212,0.10),transparent_38%)]" />
|
||||
<div className="absolute inset-x-6 top-5 z-10 flex h-8 items-center justify-between gap-3 rounded-xl bg-background/82 px-3 ring-1 ring-foreground/[0.06]">
|
||||
<span className="truncate font-mono text-[10px] text-muted-foreground">{selectedReplayId ?? "All selected route clicks"}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">100% x 100%</span>
|
||||
</div>
|
||||
<div className="absolute inset-6 top-16 rounded-xl bg-foreground/[0.018] ring-1 ring-foreground/[0.06]">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="absolute inset-y-0 w-px bg-foreground/[0.045]"
|
||||
style={{ left: `${(index + 1) * (100 / 6)}%` }}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="absolute inset-x-0 h-px bg-foreground/[0.045]"
|
||||
style={{ top: `${(index + 1) * 20}%` }}
|
||||
/>
|
||||
))}
|
||||
<div className="absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-cyan-500/[0.13]" />
|
||||
<div className="absolute left-0 top-1/2 h-px w-full -translate-y-1/2 bg-cyan-500/[0.13]" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPanel({ label }: { label: string }) {
|
||||
return <div className="rounded-lg bg-foreground/[0.03] p-4 text-center text-xs text-muted-foreground">{label}</div>;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Analytics Heatmaps",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return <PageClient />;
|
||||
}
|
||||
@ -2,5 +2,5 @@ import { redirect } from "next/navigation";
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ projectId: string }> }) {
|
||||
const { projectId } = await params;
|
||||
redirect(`/projects/${projectId}/analytics/tables`);
|
||||
redirect(`/projects/${projectId}`);
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ export function PageLayout(props: {
|
||||
<div
|
||||
className={cn(
|
||||
"mb-6",
|
||||
props.wrapHeaderInCard && "rounded-2xl border border-black/[0.06] bg-white/90 px-4 py-3 shadow-[0_2px_12px_rgba(0,0,0,0.04)] backdrop-blur-xl sm:px-5 sm:py-4 dark:border-0 dark:bg-transparent dark:shadow-none dark:backdrop-blur-none dark:rounded-none dark:px-0 dark:py-0 dark:sm:px-0 dark:sm:py-0"
|
||||
props.wrapHeaderInCard && "rounded-2xl border border-black/[0.06] bg-white/90 p-4 shadow-[0_2px_12px_rgba(0,0,0,0.04)] backdrop-blur-xl sm:p-5 dark:border-0 dark:bg-transparent dark:shadow-none dark:backdrop-blur-none dark:rounded-none dark:p-0 dark:sm:p-0"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
|
||||
|
||||
@ -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"
|
||||
);
|
||||
|
||||
@ -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<string, unknown>[]): DauRow[] {
|
||||
.filter((r) => r.day.length > 0);
|
||||
}
|
||||
|
||||
function parseHeatmap(rows: Record<string, unknown>[]): 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<string, unknown>[]): ContributorRow[] {
|
||||
const result: ContributorRow[] = [];
|
||||
for (const row of rows) {
|
||||
@ -323,10 +300,11 @@ export function TeamAnalyticsSection({ team }: { team: ServerTeam }) {
|
||||
prev7dSince: toClickhouseDateTimeParam(prev7dSince),
|
||||
}),
|
||||
runQuery(DAU_QUERY, baseParams),
|
||||
runQuery(HEATMAP_QUERY, {
|
||||
memberIds,
|
||||
since: toClickhouseDateTimeParam(heatmapSince),
|
||||
until: toClickhouseDateTimeParam(now),
|
||||
stackAdminApp.getAnalyticsHeatmap({
|
||||
kind: "team_user_hour_of_week",
|
||||
member_user_ids: memberIds,
|
||||
since: heatmapSince.toISOString(),
|
||||
until: now.toISOString(),
|
||||
}),
|
||||
runQuery(TOP_CONTRIBUTORS_QUERY, { ...baseParams, limit: TOP_CONTRIBUTORS_LIMIT }),
|
||||
]);
|
||||
@ -351,7 +329,9 @@ export function TeamAnalyticsSection({ team }: { team: ServerTeam }) {
|
||||
data: {
|
||||
summary: summaryRes.status === "fulfilled" ? parseSummary(summaryRes.value.result) : emptySummary,
|
||||
dau: dauRes.status === "fulfilled" ? parseDau(dauRes.value.result) : [],
|
||||
heatmap: heatmapRes.status === "fulfilled" ? parseHeatmap(heatmapRes.value.result) : [],
|
||||
heatmap: heatmapRes.status === "fulfilled"
|
||||
? heatmapRes.value.cells.map((cell) => ({ dow: cell.weekday, hour: cell.hour, active_users: cell.value }))
|
||||
: [],
|
||||
contributors: contributorsRes.status === "fulfilled" ? parseContributors(contributorsRes.value.result) : [],
|
||||
},
|
||||
});
|
||||
|
||||
@ -393,6 +393,7 @@ export const ALL_APPS_FRONTEND = {
|
||||
href: "analytics",
|
||||
navigationItems: [
|
||||
{ displayName: "Tables", href: "./tables" },
|
||||
{ displayName: "Heatmaps", href: "./heatmaps" },
|
||||
{ displayName: "Replays", href: "../session-replays" },
|
||||
{ displayName: "Queries", href: "./queries" },
|
||||
],
|
||||
|
||||
@ -6,7 +6,7 @@ import type { MoneyAmount } from "../utils/currency-constants";
|
||||
import type { Json } from "../utils/json";
|
||||
import { Result } from "../utils/results";
|
||||
import { urlString } from "../utils/urls";
|
||||
import type { MetricsResponse, MetricsUserCounts, UserActivityResponse } from "./admin-metrics";
|
||||
import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse, 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,29 @@ export class HexclaveAdminInterface extends HexclaveServerInterface {
|
||||
return (await response.json()) as UserActivityResponse;
|
||||
}
|
||||
|
||||
async getAnalyticsHeatmap(options: {
|
||||
kind: "team_user_hour_of_week" | "session_replay_clicks",
|
||||
member_user_ids?: string[],
|
||||
route_path?: string,
|
||||
route_regex?: string,
|
||||
user_id?: string,
|
||||
replay_id?: string,
|
||||
device?: AnalyticsHeatmapDevice,
|
||||
since: string,
|
||||
until: string,
|
||||
}): Promise<AnalyticsHeatmapResponse> {
|
||||
const response = await this.sendAdminRequest(
|
||||
"/internal/analytics/heatmap",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(options),
|
||||
},
|
||||
null,
|
||||
);
|
||||
return (await response.json()) as AnalyticsHeatmapResponse;
|
||||
}
|
||||
|
||||
async getMetricsUserCounts(): Promise<MetricsUserCounts> {
|
||||
const response = await this.sendAdminRequest(
|
||||
"/internal/metrics/user-counts",
|
||||
|
||||
@ -179,6 +179,53 @@ export const UserActivityResponseBodySchema = yupObject({
|
||||
data_points: MetricsDataPointsSchema,
|
||||
}).defined();
|
||||
|
||||
export const AnalyticsHeatmapKindSchema = yupString().oneOf(["team_user_hour_of_week", "session_replay_clicks"]).defined();
|
||||
export const AnalyticsHeatmapDeviceSchema = yupString().oneOf(["tv", "widescreen", "desktop", "laptop", "tablet", "mobile"]).defined();
|
||||
|
||||
export const AnalyticsHeatmapCellSchema = yupObject({
|
||||
weekday: yupNumber().integer().min(1).max(7).defined(),
|
||||
hour: yupNumber().integer().min(0).max(23).defined(),
|
||||
value: yupNumber().integer().defined(),
|
||||
}).defined();
|
||||
|
||||
export const AnalyticsHeatmapResponseBodySchema = yupObject({
|
||||
kind: AnalyticsHeatmapKindSchema,
|
||||
cells: yupArray(AnalyticsHeatmapCellSchema).defined(),
|
||||
points: yupArray(yupObject({
|
||||
x_percent: yupNumber().defined(),
|
||||
y_percent: yupNumber().defined(),
|
||||
count: yupNumber().integer().defined(),
|
||||
}).defined()).optional().default([]),
|
||||
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([]),
|
||||
}).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 +290,7 @@ export type MetricsRecentUser = yup.InferType<typeof MetricsRecentUserSchema>;
|
||||
export type MetricsResponse = yup.InferType<typeof MetricsResponseBodySchema>;
|
||||
export type MetricsUserCounts = yup.InferType<typeof MetricsUserCountsSchema>;
|
||||
export type UserActivityResponse = yup.InferType<typeof UserActivityResponseBodySchema>;
|
||||
export type AnalyticsHeatmapKind = yup.InferType<typeof AnalyticsHeatmapKindSchema>;
|
||||
export type AnalyticsHeatmapDevice = yup.InferType<typeof AnalyticsHeatmapDeviceSchema>;
|
||||
export type AnalyticsHeatmapCell = yup.InferType<typeof AnalyticsHeatmapCellSchema>;
|
||||
export type AnalyticsHeatmapResponse = yup.InferType<typeof AnalyticsHeatmapResponseBodySchema>;
|
||||
|
||||
@ -21,6 +21,8 @@ const Tooltip = TooltipPrimitive.Root;
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipPortal = TooltipPrimitive.Portal;
|
||||
|
||||
const TooltipContent = forwardRefIfNeeded<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
@ -37,4 +39,4 @@ const TooltipContent = forwardRefIfNeeded<
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipPortal, TooltipProvider };
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { KnownErrors, HexclaveAdminInterface } from "@stackframe/stack-shared";
|
||||
import { getProductionModeErrors } from "@stackframe/stack-shared/dist/helpers/production-mode";
|
||||
import { InternalApiKeyCreateCrudResponse } from "@stackframe/stack-shared/dist/interface/admin-interface";
|
||||
import type { MetricsResponse, MetricsUserCounts, UserActivityResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics";
|
||||
import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse, MetricsResponse, MetricsUserCounts, UserActivityResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics";
|
||||
import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@stackframe/stack-shared/dist/interface/crud/analytics";
|
||||
import { EmailTemplateCrud } from "@stackframe/stack-shared/dist/interface/crud/email-templates";
|
||||
import { InternalApiKeysCrud } from "@stackframe/stack-shared/dist/interface/crud/internal-api-keys";
|
||||
@ -1166,6 +1166,20 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
|
||||
return await this._interface.queryAnalytics(options);
|
||||
}
|
||||
|
||||
async getAnalyticsHeatmap(options: {
|
||||
kind: "team_user_hour_of_week" | "session_replay_clicks",
|
||||
member_user_ids?: string[],
|
||||
route_path?: string,
|
||||
route_regex?: string,
|
||||
user_id?: string,
|
||||
replay_id?: string,
|
||||
device?: AnalyticsHeatmapDevice,
|
||||
since: string,
|
||||
until: string,
|
||||
}): Promise<AnalyticsHeatmapResponse> {
|
||||
return await this._interface.getAnalyticsHeatmap(options);
|
||||
}
|
||||
|
||||
async listSessionReplays(options?: ListSessionReplaysOptions): Promise<ListSessionReplaysResult> {
|
||||
const response = await this._interface.listSessionReplays({
|
||||
cursor: options?.cursor,
|
||||
|
||||
@ -210,6 +210,9 @@ export class EventTracker {
|
||||
text: target.textContent.trim().substring(0, 200),
|
||||
href: this._findNearestAnchorHref(target),
|
||||
selector: this._buildSelector(target),
|
||||
url: window.location.href,
|
||||
path: window.location.pathname,
|
||||
title: document.title,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
page_x: event.pageX,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { AnalyticsHeatmapDevice, AnalyticsHeatmapResponse } from "@stackframe/stack-shared/dist/interface/admin-metrics";
|
||||
import { AnalyticsQueryOptions, AnalyticsQueryResponse } from "@stackframe/stack-shared/dist/interface/crud/analytics";
|
||||
import type { AdminGetSessionReplayChunkEventsResponse, AdminGetSessionReplayAllEventsResponse } from "@stackframe/stack-shared/dist/interface/crud/session-replays";
|
||||
import type { Transaction, TransactionType } from "@stackframe/stack-shared/dist/interface/crud/transactions";
|
||||
@ -153,6 +154,17 @@ export type StackAdminApp<HasTokenStore extends boolean = boolean, ProjectId ext
|
||||
endAction?: "now" | "at-period-end",
|
||||
}): Promise<{ refundTransactionId: string }>,
|
||||
queryAnalytics(options: AnalyticsQueryOptions): Promise<AnalyticsQueryResponse>,
|
||||
getAnalyticsHeatmap(options: {
|
||||
kind: "team_user_hour_of_week" | "session_replay_clicks",
|
||||
member_user_ids?: string[],
|
||||
route_path?: string,
|
||||
route_regex?: string,
|
||||
user_id?: string,
|
||||
replay_id?: string,
|
||||
device?: AnalyticsHeatmapDevice,
|
||||
since: string,
|
||||
until: string,
|
||||
}): Promise<AnalyticsHeatmapResponse>,
|
||||
|
||||
listSessionReplays(options?: ListSessionReplaysOptions): Promise<ListSessionReplaysResult>,
|
||||
getSessionReplay(sessionReplayId: string): Promise<AdminSessionReplay>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user