stack/packages/shared/src/interface/admin-metrics.ts
Mantra e93b7520c4
feat(analytics): add route analytics heatmaps (#1520)
## 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>
2026-06-15 12:06:16 -07:00

334 lines
16 KiB
TypeScript

import yup from "yup";
import { yupArray, yupMixed, yupNumber, yupObject, yupRecord, yupString } from "../schema-fields";
// Single source of truth for the `/internal/metrics` endpoint shape.
//
// Both the backend route handler (`apps/backend/src/app/api/latest/internal/metrics/route.tsx`)
// and the dashboard hook (`apps/dashboard/src/lib/hexclave-app-internals.ts`) import
// these schemas. The runtime validation lives in the schemas; the static types
// are derived via `yup.InferType` so we never have to keep parallel copies in
// sync.
export const MetricsDataPointSchema = yupObject({
date: yupString().defined(),
activity: yupNumber().defined(),
}).defined();
export const MetricsDataPointsSchema = yupArray(MetricsDataPointSchema).defined();
export const MetricsActivitySplitSchema = yupObject({
total: MetricsDataPointsSchema,
new: MetricsDataPointsSchema,
retained: MetricsDataPointsSchema,
reactivated: MetricsDataPointsSchema,
}).defined();
export const MetricsAuthOverviewSchema = yupObject({
verified_users: yupNumber().integer().defined(),
unverified_users: yupNumber().integer().defined(),
anonymous_users: yupNumber().integer().defined(),
total_teams: yupNumber().integer().defined(),
mau: yupNumber().integer().defined(),
daily_active_users_split: MetricsActivitySplitSchema,
daily_active_teams_split: MetricsActivitySplitSchema,
total_users_filtered: yupNumber().integer().defined(),
}).defined();
export const MetricsPaymentsOverviewSchema = yupObject({
subscriptions_by_status: yupRecord(yupString().defined(), yupNumber().defined()).defined(),
active_subscription_count: yupNumber().integer().defined(),
total_one_time_purchases: yupNumber().integer().defined(),
daily_subscriptions: MetricsDataPointsSchema,
revenue_cents: yupNumber().integer().defined(),
mrr_cents: yupNumber().integer().defined(),
total_orders: yupNumber().integer().defined(),
checkout_conversion_rate: yupNumber().defined(),
}).defined();
export const MetricsRecentEmailSchema = yupObject({
id: yupString().defined(),
status: yupString().defined(),
subject: yupString().defined(),
created_at_millis: yupNumber().defined(),
}).defined();
export const MetricsDailyEmailStatusBreakdownSchema = yupObject({
date: yupString().defined(),
ok: yupNumber().integer().defined(),
error: yupNumber().integer().defined(),
in_progress: yupNumber().integer().defined(),
}).defined();
export const MetricsEmailOverviewSchema = yupObject({
emails_by_status: yupRecord(yupString().defined(), yupNumber().defined()).defined(),
total_emails: yupNumber().integer().defined(),
daily_emails: MetricsDataPointsSchema,
daily_emails_by_status: yupArray(MetricsDailyEmailStatusBreakdownSchema).defined(),
emails_sent: yupNumber().integer().defined(),
recent_emails: yupArray(MetricsRecentEmailSchema).defined(),
deliverability_status: yupObject({
delivered: yupNumber().integer().defined(),
bounced: yupNumber().integer().defined(),
error: yupNumber().integer().defined(),
in_progress: yupNumber().integer().defined(),
}).defined(),
deliverability_rate: yupNumber().defined(),
bounce_rate: yupNumber().defined(),
click_rate: yupNumber().defined(),
}).defined();
export const MetricsDailyRevenuePointSchema = yupObject({
date: yupString().defined(),
new_cents: yupNumber().integer().defined(),
refund_cents: yupNumber().integer().defined(),
}).defined();
export const MetricsTopReferrerSchema = yupObject({
referrer: yupString().defined(),
visitors: yupNumber().integer().defined(),
}).defined();
// Named-count breakdowns used by the analytics overview for top browsers,
// operating systems, and device classes (Desktop / Mobile / Tablet).
export const MetricsNamedCountSchema = yupObject({
name: yupString().defined(),
visitors: yupNumber().integer().defined(),
}).defined();
export const MetricsTopRegionSchema = yupObject({
country_code: yupString().nullable().defined(),
region_code: yupString().nullable().defined(),
count: yupNumber().integer().defined(),
}).defined();
export const MetricsTopCountrySchema = yupObject({
country_code: yupString().defined(),
count: yupNumber().integer().defined(),
}).defined();
export const MetricsAnalyticsOverviewSchema = yupObject({
daily_page_views: MetricsDataPointsSchema,
daily_clicks: MetricsDataPointsSchema,
daily_visitors: MetricsDataPointsSchema,
hourly_page_views: yupArray(MetricsDataPointSchema).optional().default([]),
hourly_active_users: yupArray(MetricsDataPointSchema).optional().default([]),
hourly_visitors: yupArray(MetricsDataPointSchema).optional().default([]),
// Token-refresh-derived anonymous-visitor fallback. Populated only when the
// analytics app isn't installed (no `$page-view` events) — counts DISTINCT
// anonymous users per day from the events table. See
// `loadAnonymousVisitorsFromTokenRefresh` in the backend metrics route.
//
// Optional for one release cycle so older clients/servers don't hard-fail
// validation during a staged rollout. Tighten to `.defined()` after.
daily_anonymous_visitors_fallback: yupArray(MetricsDataPointSchema).optional().default([]),
daily_revenue: yupArray(MetricsDailyRevenuePointSchema).defined(),
total_revenue_cents: yupNumber().integer().defined(),
total_replays: yupNumber().integer().defined(),
recent_replays: yupNumber().integer().defined(),
visitors: yupNumber().integer().defined(),
anonymous_visitors_fallback: yupNumber().integer().optional().default(0),
avg_session_seconds: yupNumber().defined(),
online_live: yupNumber().integer().defined(),
revenue_per_visitor: yupNumber().defined(),
top_referrers: yupArray(MetricsTopReferrerSchema).defined(),
top_region: MetricsTopRegionSchema.nullable().defined(),
top_regions: yupArray(MetricsTopCountrySchema).optional().default([]),
// Weighted across the window: sum(bounced)/sum(sessions) * 100. .optional()
// for one release cycle so older servers (that don't return it yet) don't
// hard-fail validation; default to 0 so consumers can read unconditionally.
bounce_rate: yupNumber().optional().default(0),
daily_bounce_rate: yupArray(MetricsDataPointSchema).optional().default([]),
daily_avg_session_seconds: yupArray(MetricsDataPointSchema).optional().default([]),
// User-Agent-derived breakdowns for the analytics overview. Computed from the
// `data.user_agent` blob on `$page-view` events, captured client-side.
// Optional + default-[] for one release cycle so older clients / servers
// without UA capture don't fail validation.
top_browsers: yupArray(MetricsNamedCountSchema).optional().default([]),
top_operating_systems: yupArray(MetricsNamedCountSchema).optional().default([]),
top_devices: yupArray(MetricsNamedCountSchema).optional().default([]),
conversion_rate: yupNumber().optional(),
deltas: yupMixed().optional(),
}).defined();
export const MetricsLoginMethodEntrySchema = yupObject({
method: yupString().defined(),
count: yupNumber().integer().defined(),
}).defined();
// Recent user summary — a slim projection of UsersCrud["Admin"]["Read"] with
// just the fields the overview list views actually read. Defined here so the
// metrics response stays self-contained and doesn't pull in users-crud.
//
// `.noUnknown(false)` tells our strict object validator to allow the extra
// fields that the backend returns as part of the full users-crud payload —
// `is_anonymous`, `has_password`, `oauth_providers`, etc. — without rejecting
// them. Only the keys explicitly listed here are validated; extras pass through.
export const MetricsRecentUserSchema = yupObject({
id: yupString().defined(),
display_name: yupString().nullable().defined(),
primary_email: yupString().nullable().defined(),
profile_image_url: yupString().nullable().defined(),
signed_up_at_millis: yupNumber().defined(),
last_active_at_millis: yupNumber().nullable().defined(),
}).noUnknown(false).defined();
// 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(),
// Optional for one release cycle so new overlay clients can validate
// responses from older servers that only returned aggregate click counts.
dead_clicks: yupNumber().integer().optional().default(0),
}).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
// real avatars of real users from each country. Optional for one release cycle
// so clients talking to older servers don't fail validation on the returned
// body.
export const MetricsActiveUsersByCountrySchema = yupRecord(
yupString().defined(),
yupArray(MetricsRecentUserSchema).defined(),
).optional().default({});
export const MetricsResponseBodySchema = yupObject({
total_users: yupNumber().integer().defined(),
// Count of distinct users seen refreshing a token in the last ~2 minutes —
// the "who's online right now" number rendered on the overview globe. Derived
// from the same `$token-refresh` window that powers `active_users_by_country`,
// so it works for every project regardless of whether the analytics app
// (page-view-based `analytics_overview.online_live`) is installed.
//
// Optional for one release cycle so older servers don't fail schema
// validation on the returned body. Tighten to `.defined()` after.
live_users: yupNumber().integer().optional().default(0),
daily_users: MetricsDataPointsSchema,
daily_active_users: MetricsDataPointsSchema,
hourly_users: yupArray(MetricsDataPointSchema).optional().default([]),
hourly_active_users: yupArray(MetricsDataPointSchema).optional().default([]),
users_by_country: yupRecord(yupString().defined(), yupNumber().defined()).defined(),
active_users_by_country: MetricsActiveUsersByCountrySchema,
// recently_registered/active are CRUD User objects passed through from the
// backend. The schema only validates the fields the dashboard actually
// reads — extra fields from UsersCrud["Admin"]["Read"] are allowed through.
recently_registered: yupArray(MetricsRecentUserSchema).defined(),
recently_active: yupArray(MetricsRecentUserSchema).defined(),
login_methods: yupArray(MetricsLoginMethodEntrySchema).defined(),
auth_overview: MetricsAuthOverviewSchema,
payments_overview: MetricsPaymentsOverviewSchema,
email_overview: MetricsEmailOverviewSchema,
analytics_overview: MetricsAnalyticsOverviewSchema,
}).defined();
export const MetricsUserCountsSchema = yupObject({
total_users: yupNumber().integer().defined(),
anonymous_users: yupNumber().integer().defined(),
}).defined();
// Derived static types — single source of truth lives in the schemas above.
export type MetricsDataPoint = yup.InferType<typeof MetricsDataPointSchema>;
export type MetricsActivitySplit = yup.InferType<typeof MetricsActivitySplitSchema>;
export type MetricsAuthOverview = yup.InferType<typeof MetricsAuthOverviewSchema>;
export type MetricsPaymentsOverview = yup.InferType<typeof MetricsPaymentsOverviewSchema>;
export type MetricsRecentEmail = yup.InferType<typeof MetricsRecentEmailSchema>;
export type MetricsDailyEmailStatusBreakdown = yup.InferType<typeof MetricsDailyEmailStatusBreakdownSchema>;
export type MetricsEmailOverview = yup.InferType<typeof MetricsEmailOverviewSchema>;
export type MetricsDailyRevenuePoint = yup.InferType<typeof MetricsDailyRevenuePointSchema>;
export type MetricsTopReferrer = yup.InferType<typeof MetricsTopReferrerSchema>;
export type MetricsTopRegion = yup.InferType<typeof MetricsTopRegionSchema>;
export type MetricsTopCountry = yup.InferType<typeof MetricsTopCountrySchema>;
export type MetricsNamedCount = yup.InferType<typeof MetricsNamedCountSchema>;
export type MetricsAnalyticsOverview = yup.InferType<typeof MetricsAnalyticsOverviewSchema>;
export type MetricsLoginMethodEntry = yup.InferType<typeof MetricsLoginMethodEntrySchema>;
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 AnalyticsClickmapKind = yup.InferType<typeof AnalyticsClickmapKindSchema>;
export type AnalyticsClickmapDevice = yup.InferType<typeof AnalyticsClickmapDeviceSchema>;
export type AnalyticsClickmapCell = yup.InferType<typeof AnalyticsClickmapCellSchema>;
export type AnalyticsClickmapResponse = yup.InferType<typeof AnalyticsClickmapResponseBodySchema>;
export type AnalyticsClickmapTokenResponse = yup.InferType<typeof AnalyticsClickmapTokenResponseBodySchema>;
// 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,
};