Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a1da657
fix replay pausing issue
BilalG1 Feb 17, 2026
39ca118
analytics event tracking
BilalG1 Feb 17, 2026
f1fde88
fix test, remove type cast
BilalG1 Feb 17, 2026
fb49d11
analytics domain override
BilalG1 Feb 18, 2026
fa09166
Merge branch 'dev' into analytics-event-tracking
BilalG1 Feb 18, 2026
2aadf87
fix light mode replays page height
BilalG1 Feb 18, 2026
97055c9
known errors for analytics disabled
BilalG1 Feb 18, 2026
f44ad77
Merge branch 'analytics-event-tracking' of https://github.com/stack-a…
BilalG1 Feb 18, 2026
874f5ba
Merge branch 'analytics-event-tracking' into analytics-domain-override
BilalG1 Feb 18, 2026
cb17836
working replay event markers
BilalG1 Feb 18, 2026
0349c8c
moved marker, fixed tooltips
BilalG1 Feb 18, 2026
e7a050a
session replay missing snapshot fix
BilalG1 Feb 18, 2026
cca8798
Merge branch 'dev' into analytics-replays-event-markers
BilalG1 Feb 18, 2026
9e74bc2
Merge branch 'dev' into analytics-replays-event-markers
BilalG1 Feb 18, 2026
e545adc
analytics-anon-users
BilalG1 Feb 18, 2026
644f341
clear buffer on sign out
BilalG1 Feb 18, 2026
e10cf16
analytics replay filters
BilalG1 Feb 19, 2026
58b3905
fix lint and typecheck, add session-replay tests
BilalG1 Feb 19, 2026
137c4ea
simplify getAnalyticsAccessToken
BilalG1 Feb 19, 2026
466fcba
Merge branch 'analytics-anon-users' into analytics-replay-filters
BilalG1 Feb 19, 2026
c0e6946
Merge branch 'dev' into analytics-replays-event-markers
BilalG1 Feb 19, 2026
9d774fc
Merge branch 'analytics-replays-event-markers' into analytics-anon-users
BilalG1 Feb 19, 2026
d7cd237
Merge branch 'analytics-anon-users' into analytics-replay-filters
BilalG1 Feb 19, 2026
d590207
analytics: use anonymous users (#1211)
BilalG1 Feb 19, 2026
cdb8cd0
Merge branch 'analytics-replays-event-markers' into analytics-replay-…
BilalG1 Feb 19, 2026
9997ebf
merge dev
BilalG1 Feb 19, 2026
91f1eed
use external clickhouse user
BilalG1 Feb 19, 2026
58b5a56
single query
BilalG1 Feb 19, 2026
435a5f7
Merge branch 'dev' into analytics-replay-filters
BilalG1 Feb 19, 2026
bd2d053
add replays link to source user
BilalG1 Feb 19, 2026
bfd8c0a
Merge branch 'analytics-replay-filters' of https://github.com/stack-a…
BilalG1 Feb 19, 2026
68eecc6
add / fix session replay tests
BilalG1 Feb 19, 2026
5120e08
import stack-shared uuid check
BilalG1 Feb 20, 2026
9138d54
Merge branch 'dev' into analytics-replay-filters
BilalG1 Feb 24, 2026
51299e4
fix test
BilalG1 Feb 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 180 additions & 60 deletions apps/backend/src/app/api/latest/internal/session-replays/route.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,82 @@
import { getPrismaClientForTenancy } from "@/prisma-client";
import { getClickhouseExternalClient } from "@/lib/clickhouse";
import { Prisma } from "@/generated/prisma/client";
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { BooleanTrue, Prisma } from "@/generated/prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids";

const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
const CLICK_FILTER_ID_CAP = 1000;

function parseCsvIds(raw: string | undefined): string[] {
if (!raw) return [];
return raw.split(",").map(s => s.trim()).filter(Boolean);
}

function parseCsvUuids(name: string, raw: string | undefined): string[] {
const values = parseCsvIds(raw);
for (const value of values) {
if (!isUuid(value)) {
throw new StatusError(StatusError.BadRequest, `${name} must contain valid UUID values`);
}
}
return values;
}

function parseNonNegativeInt(name: string, raw: string | undefined): number | null {
if (!raw) return null;
const value = Number(raw);
if (!Number.isInteger(value) || value < 0) {
throw new StatusError(StatusError.BadRequest, `${name} must be a non-negative integer`);
}
return value;
}

function parseMillis(name: string, raw: string | undefined): Date | null {
if (!raw) return null;
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
throw new StatusError(StatusError.BadRequest, `${name} must be a non-negative timestamp in milliseconds`);
}
return new Date(value);
}

async function loadClickQualifiedReplayIds(options: {
projectId: string,
branchId: string,
clickCountMin: number,
}): Promise<string[]> {
const clickhouseClient = getClickhouseExternalClient();
const result = await clickhouseClient.query({
query: `
SELECT session_replay_id
FROM default.events
WHERE project_id = {projectId:String}
AND branch_id = {branchId:String}
AND event_type = '$click'
GROUP BY session_replay_id
HAVING count() >= {clickCountMin:UInt64}
LIMIT {cap:UInt64}
`,
query_params: {
projectId: options.projectId,
branchId: options.branchId,
clickCountMin: options.clickCountMin,
cap: CLICK_FILTER_ID_CAP,
},
clickhouse_settings: {
SQL_project_id: options.projectId,
SQL_branch_id: options.branchId,
},
format: "JSONEachRow",
});

const rows = await result.json() as Array<{ session_replay_id: string }>;
Comment thread
BilalG1 marked this conversation as resolved.
return rows.map((row) => row.session_replay_id);
}
Comment thread
BilalG1 marked this conversation as resolved.

export const GET = createSmartRouteHandler({
metadata: { hidden: true },
Expand All @@ -17,6 +88,13 @@ export const GET = createSmartRouteHandler({
query: yupObject({
cursor: yupString().optional(),
limit: yupString().optional(),
user_ids: yupString().optional(),
team_ids: yupString().optional(),
duration_ms_min: yupString().optional(),
duration_ms_max: yupString().optional(),
last_event_at_from_millis: yupString().optional(),
last_event_at_to_millis: yupString().optional(),
click_count_min: yupString().optional(),
}).optional(),
}),
response: yupObject({
Expand All @@ -42,69 +120,120 @@ export const GET = createSmartRouteHandler({
}),
async handler({ auth, query }) {
const prisma = await getPrismaClientForTenancy(auth.tenancy);
const schema = await getPrismaSchemaForTenancy(auth.tenancy);

const rawLimit = query.limit ?? String(DEFAULT_LIMIT);
const parsedLimit = Number.parseInt(rawLimit, 10);
const limit = Math.max(1, Math.min(MAX_LIMIT, Number.isFinite(parsedLimit) ? parsedLimit : DEFAULT_LIMIT));

const userIdsFilter = parseCsvUuids("user_ids", query.user_ids);
const teamIdsFilter = parseCsvUuids("team_ids", query.team_ids);
const durationMsMin = parseNonNegativeInt("duration_ms_min", query.duration_ms_min);
const durationMsMax = parseNonNegativeInt("duration_ms_max", query.duration_ms_max);
const clickCountMin = parseNonNegativeInt("click_count_min", query.click_count_min);
const lastEventAtFrom = parseMillis("last_event_at_from_millis", query.last_event_at_from_millis);
const lastEventAtTo = parseMillis("last_event_at_to_millis", query.last_event_at_to_millis);

if (durationMsMin !== null && durationMsMax !== null && durationMsMin > durationMsMax) {
throw new StatusError(StatusError.BadRequest, "duration_ms_min must be less than or equal to duration_ms_max");
}
if (lastEventAtFrom && lastEventAtTo && lastEventAtFrom.getTime() > lastEventAtTo.getTime()) {
throw new StatusError(StatusError.BadRequest, "last_event_at_from_millis must be less than or equal to last_event_at_to_millis");
}

// If click filter is active, get qualifying replay IDs from ClickHouse in one query
const clickQualifiedIds = clickCountMin && clickCountMin > 0
? await loadClickQualifiedReplayIds({
projectId: auth.tenancy.project.id,
branchId: auth.tenancy.branchId,
clickCountMin,
})
: null;

if (clickQualifiedIds && clickQualifiedIds.length === 0) {
return {
statusCode: 200,
bodyType: "json",
body: { items: [], pagination: { next_cursor: null } },
};
}

// Handle cursor-based pagination
const cursorId = query.cursor;
let cursorPivot: { lastEventAt: Date } | null = null;
let cursorPivot: { id: string, lastEventAt: Date } | null = null;
if (cursorId) {
cursorPivot = await prisma.sessionReplay.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: cursorId } },
select: { lastEventAt: true },
select: { id: true, lastEventAt: true },
});
if (!cursorPivot) {
throw new KnownErrors.ItemNotFound(cursorId);
}
}

const where: Prisma.SessionReplayWhereInput = cursorId && cursorPivot ? {
OR: [
{ lastEventAt: { lt: cursorPivot.lastEventAt } },
{ AND: [{ lastEventAt: { equals: cursorPivot.lastEventAt } }, { id: { lt: cursorId } }] },
],
} : {};

const sessions = await prisma.sessionReplay.findMany({
where: { tenancyId: auth.tenancy.id, ...where },
orderBy: [{ lastEventAt: "desc" }, { id: "desc" }],
take: limit + 1,
select: {
id: true,
projectUserId: true,
startedAt: true,
lastEventAt: true,
},
});
type ReplayRow = {
id: string,
projectUserId: string,
startedAt: Date,
lastEventAt: Date,
projectUserDisplayName: string | null,
primaryEmail: string | null,
};

const hasMore = sessions.length > limit;
const page = hasMore ? sessions.slice(0, limit) : sessions;
const nextCursor = hasMore ? page[page.length - 1]!.id : null;
const rows = await prisma.$queryRaw<ReplayRow[]>`
SELECT
sr."id",
sr."projectUserId",
sr."startedAt",
sr."lastEventAt",
pu."displayName" AS "projectUserDisplayName",
(
SELECT cc."value"
FROM ${sqlQuoteIdent(schema)}."ContactChannel" cc
WHERE cc."projectUserId" = sr."projectUserId"
AND cc."tenancyId" = sr."tenancyId"
AND cc."type" = 'EMAIL'
AND cc."isPrimary" = 'TRUE'::"BooleanTrue"
LIMIT 1
) AS "primaryEmail"
FROM ${sqlQuoteIdent(schema)}."SessionReplay" sr
JOIN ${sqlQuoteIdent(schema)}."ProjectUser" pu
ON pu."projectUserId" = sr."projectUserId"
AND pu."tenancyId" = sr."tenancyId"
WHERE sr."tenancyId" = ${auth.tenancy.id}::UUID
${userIdsFilter.length > 0 ? Prisma.sql`AND sr."projectUserId" IN (${Prisma.join(userIdsFilter)})` : Prisma.empty}
${lastEventAtFrom ? Prisma.sql`AND sr."lastEventAt" >= ${lastEventAtFrom}` : Prisma.empty}
${lastEventAtTo ? Prisma.sql`AND sr."lastEventAt" <= ${lastEventAtTo}` : Prisma.empty}
${teamIdsFilter.length > 0 ? Prisma.sql`AND EXISTS (
SELECT 1 FROM ${sqlQuoteIdent(schema)}."TeamMember" tm
WHERE tm."projectUserId" = sr."projectUserId"
AND tm."tenancyId" = sr."tenancyId"
AND tm."teamId" IN (${Prisma.join(teamIdsFilter)})
)` : Prisma.empty}
${clickQualifiedIds ? Prisma.sql`AND sr."id" IN (${Prisma.join(clickQualifiedIds)})` : Prisma.empty}
${durationMsMin !== null ? Prisma.sql`AND EXTRACT(EPOCH FROM (sr."lastEventAt" - sr."startedAt")) * 1000 >= ${durationMsMin}` : Prisma.empty}
${durationMsMax !== null ? Prisma.sql`AND EXTRACT(EPOCH FROM (sr."lastEventAt" - sr."startedAt")) * 1000 <= ${durationMsMax}` : Prisma.empty}
${cursorPivot ? Prisma.sql`AND (
sr."lastEventAt" < ${cursorPivot.lastEventAt}
OR (sr."lastEventAt" = ${cursorPivot.lastEventAt} AND sr."id" < ${cursorId})
)` : Prisma.empty}
Comment thread
BilalG1 marked this conversation as resolved.
ORDER BY sr."lastEventAt" DESC, sr."id" DESC
LIMIT ${limit + 1}
`;

const sessionIds = page.map(s => s.id);
const userIds = [...new Set(page.map(s => s.projectUserId))];
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore ? page[page.length - 1]!.id : null;
Comment thread
BilalG1 marked this conversation as resolved.

const [chunkAggs, users] = await Promise.all([
sessionIds.length ? prisma.sessionReplayChunk.groupBy({
const sessionIds = page.map((row) => row.id);
const chunkAggs = sessionIds.length
? await prisma.sessionReplayChunk.groupBy({
by: ["sessionReplayId"],
where: { tenancyId: auth.tenancy.id, sessionReplayId: { in: sessionIds } },
_count: { _all: true },
_sum: { eventCount: true },
}) : Promise.resolve([] as Array<{ sessionReplayId: string, _count: { _all: number }, _sum: { eventCount: number | null } }>),
userIds.length ? prisma.projectUser.findMany({
where: { tenancyId: auth.tenancy.id, projectUserId: { in: userIds } },
select: {
projectUserId: true,
displayName: true,
contactChannels: {
where: { type: "EMAIL", isPrimary: BooleanTrue.TRUE },
select: { value: true },
take: 1,
},
},
}) : Promise.resolve([] as Array<{ projectUserId: string, displayName: string | null, contactChannels: Array<{ value: string }> }>),
]);
})
: [];

const aggBySessionId = new Map<string, { chunkCount: number, eventCount: number }>();
for (const a of chunkAggs) {
Expand All @@ -114,30 +243,21 @@ export const GET = createSmartRouteHandler({
});
}

const userById = new Map<string, { displayName: string | null, primaryEmail: string | null }>();
for (const u of users) {
userById.set(u.projectUserId, {
displayName: u.displayName,
primaryEmail: u.contactChannels[0]?.value ?? null,
});
}

return {
statusCode: 200,
bodyType: "json",
body: {
items: page.map((s) => {
const user = userById.get(s.projectUserId);
const agg = aggBySessionId.get(s.id) ?? { chunkCount: 0, eventCount: 0 };
items: page.map((row) => {
const agg = aggBySessionId.get(row.id) ?? { chunkCount: 0, eventCount: 0 };
return {
id: s.id,
id: row.id,
project_user: {
id: s.projectUserId,
display_name: user?.displayName ?? null,
primary_email: user?.primaryEmail ?? null,
id: row.projectUserId,
display_name: row.projectUserDisplayName ?? null,
primary_email: row.primaryEmail ?? null,
},
started_at_millis: s.startedAt.getTime(),
last_event_at_millis: s.lastEventAt.getTime(),
started_at_millis: row.startedAt.getTime(),
last_event_at_millis: row.lastEventAt.getTime(),
chunk_count: agg.chunkCount,
event_count: agg.eventCount,
};
Expand Down
Loading
Loading