mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-03 21:02:05 +08:00
## Summary Adds route analytics heatmaps, stacked on top of `codex/analytics-overview-filters` (#1496). - Heatmap API routes (`/analytics/heatmap`, internal heatmap + heatmap-token endpoints) - Signed heatmap token signing/verification lib + tests - Dashboard heatmaps page (client + route) - Dev-tool + event-tracker support for heatmap capture - ClickHouse migration support ## Demo https://app.devin.ai/attachments/49cd6a96-8962-46d9-b8fb-145746cc6dee/rec-c80ec66f-21a3-49fb-bfae-19195ce7b930-edited.mp4 ## Notes Base branch is `codex/analytics-overview-filters` so the diff shows only the heatmap changes. Will retarget to `dev` once the base PR lands. Link to Devin session: https://app.devin.ai/sessions/16f8adac29b948b38280c85418617fea Requested by: @mantrakp04 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added clickmap overlay to analytics dashboard, enabling visual click heatmap analysis on live websites. * Enhanced analytics metrics with hourly breakdowns, bounce rates, and top regions/browsers/devices filtering. * **Bug Fixes** * Improved click event tracking accuracy and dead-click detection. * Fixed overlay z-index stacking for better visibility. * **Style** * Updated dashboard card padding and navigation button styling for consistency. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: mantra <mantra@stack-auth.com> Co-authored-by: Cursor <cursoragent@cursor.com>
92 lines
2.8 KiB
TypeScript
92 lines
2.8 KiB
TypeScript
import { wait } from "@hexclave/shared/dist/utils/promises";
|
|
import { randomUUID } from "node:crypto";
|
|
import { it } from "../../../../helpers";
|
|
import { Auth, Project, niceBackendFetch } from "../../../backend-helpers";
|
|
|
|
// Matches USER_ACTIVITY_WINDOW_DAYS in
|
|
// apps/backend/src/app/api/latest/internal/user-activity/route.tsx.
|
|
// When that constant changes, bump this one too.
|
|
const USER_ACTIVITY_WINDOW_DAYS = 22 * 16;
|
|
|
|
it("should return an empty activity clickmap for an unknown user", async ({ expect }) => {
|
|
await Project.createAndSwitch({
|
|
config: {
|
|
magic_link_enabled: true,
|
|
},
|
|
});
|
|
|
|
const response = await niceBackendFetch(
|
|
`/api/v1/internal/user-activity?user_id=${randomUUID()}`,
|
|
{ accessType: "admin" },
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(Array.isArray(response.body.data_points)).toBe(true);
|
|
expect(response.body.data_points).toHaveLength(USER_ACTIVITY_WINDOW_DAYS);
|
|
for (const point of response.body.data_points) {
|
|
expect(typeof point.date).toBe("string");
|
|
expect(point.activity).toBe(0);
|
|
}
|
|
});
|
|
|
|
it("should record activity for a real user that has signed in", async ({ expect }) => {
|
|
await Project.createAndSwitch({
|
|
config: {
|
|
magic_link_enabled: true,
|
|
},
|
|
});
|
|
|
|
const { userId } = await Auth.Otp.signIn();
|
|
|
|
// ClickHouse ingestion is async; poll until the signed-in user shows up.
|
|
let totalActivity = 0;
|
|
for (let i = 0; i < 15; i += 1) {
|
|
const response = await niceBackendFetch(
|
|
`/api/v1/internal/user-activity?user_id=${userId}`,
|
|
{ accessType: "admin" },
|
|
);
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.data_points).toHaveLength(USER_ACTIVITY_WINDOW_DAYS);
|
|
totalActivity = response.body.data_points.reduce(
|
|
(sum: number, point: { activity: number }) => sum + point.activity,
|
|
0,
|
|
);
|
|
if (totalActivity > 0) break;
|
|
await wait(2_000);
|
|
}
|
|
expect(totalActivity).toBeGreaterThan(0);
|
|
}, {
|
|
timeout: 60_000,
|
|
});
|
|
|
|
it("should reject non-admin callers", async ({ expect }) => {
|
|
await Project.createAndSwitch({
|
|
config: {
|
|
magic_link_enabled: true,
|
|
},
|
|
});
|
|
|
|
const response = await niceBackendFetch(
|
|
`/api/v1/internal/user-activity?user_id=${randomUUID()}`,
|
|
{ accessType: "server" },
|
|
);
|
|
|
|
expect(response).toMatchInlineSnapshot(`
|
|
NiceResponse {
|
|
"status": 401,
|
|
"body": {
|
|
"code": "INSUFFICIENT_ACCESS_TYPE",
|
|
"details": {
|
|
"actual_access_type": "server",
|
|
"allowed_access_types": ["admin"],
|
|
},
|
|
"error": "The x-hexclave-access-type header must be 'admin', but was 'server'. (The legacy x-stack-access-type header is also accepted.)",
|
|
},
|
|
"headers": Headers {
|
|
"x-stack-known-error": "INSUFFICIENT_ACCESS_TYPE",
|
|
<some fields may have been hidden>,
|
|
},
|
|
}
|
|
`);
|
|
});
|