From f7dcc2f63950c7b912f0c2437a205e780744ea8d Mon Sep 17 00:00:00 2001 From: Madison Date: Fri, 27 Mar 2026 12:49:28 -0500 Subject: [PATCH 01/13] Added AdminGetSessionReplayResponse type for the single-replay GET endpoint response shape --- .../src/interface/crud/session-replays.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/stack-shared/src/interface/crud/session-replays.ts b/packages/stack-shared/src/interface/crud/session-replays.ts index 4027800ca4..f4a3867267 100644 --- a/packages/stack-shared/src/interface/crud/session-replays.ts +++ b/packages/stack-shared/src/interface/crud/session-replays.ts @@ -28,6 +28,19 @@ export type AdminListSessionReplaysResponse = { }, }; +export type AdminGetSessionReplayResponse = { + id: string, + project_user: { + id: string, + display_name: string | null, + primary_email: string | null, + }, + started_at_millis: number, + last_event_at_millis: number, + chunk_count: number, + event_count: number, +}; + export type AdminListSessionReplayChunksOptions = { limit?: number, cursor?: string, From f56887126e04bcec18b82a2370113fbe7b6f224a Mon Sep 17 00:00:00 2001 From: Madison Date: Fri, 27 Mar 2026 12:51:39 -0500 Subject: [PATCH 02/13] Added AdminGetSessionReplayResponse import and getSessionReplay (sessionReplayId) method to StackAdminInterface class that sends a GET to /internal/session-replays/{id} --- packages/stack-shared/src/interface/admin-interface.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index 9971ad5586..242fbabbf9 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -12,6 +12,7 @@ import { InternalApiKeysCrud } from "./crud/internal-api-keys"; import { ProjectPermissionDefinitionsCrud } from "./crud/project-permissions"; import { ProjectsCrud } from "./crud/projects"; import type { + AdminGetSessionReplayResponse, AdminGetSessionReplayAllEventsResponse, AdminGetSessionReplayChunkEventsResponse, AdminListSessionReplayChunksOptions, @@ -823,6 +824,15 @@ export class StackAdminInterface extends StackServerInterface { return await response.json(); } + async getSessionReplay(sessionReplayId: string): Promise { + const response = await this.sendAdminRequest( + `/internal/session-replays/${encodeURIComponent(sessionReplayId)}`, + { method: "GET" }, + null, + ); + return await response.json(); + } + async listSessionReplayChunks(sessionReplayId: string, params?: AdminListSessionReplayChunksOptions): Promise { const qs = new URLSearchParams(); if (params?.cursor) qs.set("cursor", params.cursor); From 0bb4e8638ff98db6404743ce7f16345b4e0f400e Mon Sep 17 00:00:00 2001 From: Madison Date: Fri, 27 Mar 2026 12:53:05 -0500 Subject: [PATCH 03/13] Added AdminSessionReplay to the import, added getSessionReplay(sessionReplayId): Promise to the StackAdminApp type --- .../template/src/lib/stack-app/apps/interfaces/admin-app.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts index e496da0f97..a92d81bb67 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts @@ -52,7 +52,7 @@ export type ManagedEmailProviderListItem = { nameServerRecords: string[], }; -import type { ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays"; +import type { AdminSessionReplay, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays"; export type { AdminSessionReplay, AdminSessionReplayChunk, ListSessionReplaysOptions, ListSessionReplaysResult, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, SessionReplayAllEventsResult } from "../../session-replays"; @@ -147,6 +147,7 @@ export type StackAdminApp, listSessionReplays(options?: ListSessionReplaysOptions): Promise, + getSessionReplay(sessionReplayId: string): Promise, listSessionReplayChunks(sessionReplayId: string, options?: ListSessionReplayChunksOptions): Promise, getSessionReplayChunkEvents(sessionReplayId: string, chunkId: string): Promise, getSessionReplayEvents(sessionReplayId: string, options?: { offset?: number, limit?: number }): Promise, From 67d847f477c42b1425d45e3ab9f25786c0585690 Mon Sep 17 00:00:00 2001 From: Madison Date: Fri, 27 Mar 2026 12:56:33 -0500 Subject: [PATCH 04/13] Added getSessionReplay implementation that calls this._interface.getSessionReplay() and maps the snake_case API response to camelCase ADminSessionReplay. --- .../apps/implementations/admin-app-impl.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 9ea25231db..76d0e0a52a 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -32,7 +32,6 @@ import { PushedConfigSource } from "../../projects"; import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like type BranchConfigSourceApi = yup.InferType; - /** * Converts a PushedConfigSource (SDK camelCase) to BranchConfigSourceApi (API snake_case). */ @@ -1133,6 +1132,22 @@ export class _StackAdminAppImplIncomplete { + const response = await this._interface.getSessionReplay(sessionReplayId); + return { + id: response.id, + projectUser: { + id: response.project_user.id, + displayName: response.project_user.display_name, + primaryEmail: response.project_user.primary_email, + }, + startedAt: new Date(response.started_at_millis), + lastEventAt: new Date(response.last_event_at_millis), + chunkCount: response.chunk_count, + eventCount: response.event_count, + }; + } + async listSessionReplayChunks(sessionReplayId: string, options?: ListSessionReplayChunksOptions): Promise { const response = await this._interface.listSessionReplayChunks(sessionReplayId, { cursor: options?.cursor, From 67c6998b8c2bc78a7327fe8d1ec551ac25d931ff Mon Sep 17 00:00:00 2001 From: Madison Date: Fri, 27 Mar 2026 13:01:10 -0500 Subject: [PATCH 05/13] Backend admin GET endpoint for fetching a single session replay by ID. Uses raw SQL to join SessinoReplay with ProjectUser and ContactChannel, aggregates chunk/event counts via Prisma groupBy. Returns 303 ItemNotFound if not found. --- .../[session_replay_id]/route.tsx | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 apps/backend/src/app/api/latest/internal/session-replays/[session_replay_id]/route.tsx diff --git a/apps/backend/src/app/api/latest/internal/session-replays/[session_replay_id]/route.tsx b/apps/backend/src/app/api/latest/internal/session-replays/[session_replay_id]/route.tsx new file mode 100644 index 0000000000..f073349545 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/session-replays/[session_replay_id]/route.tsx @@ -0,0 +1,104 @@ +import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client"; +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { KnownErrors } from "@stackframe/stack-shared"; +import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; + +type ReplayRow = { + id: string, + projectUserId: string, + startedAt: Date, + lastEventAt: Date, + projectUserDisplayName: string | null, + primaryEmail: string | null, +}; + +export const GET = createSmartRouteHandler({ + metadata: { hidden: true }, + request: yupObject({ + auth: yupObject({ + type: adminAuthTypeSchema.defined(), + tenancy: adaptSchema.defined(), + }).defined(), + params: yupObject({ + session_replay_id: yupString().defined(), + }).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["json"]).defined(), + body: yupObject({ + id: yupString().defined(), + project_user: yupObject({ + id: yupString().defined(), + display_name: yupString().nullable().defined(), + primary_email: yupString().nullable().defined(), + }).defined(), + started_at_millis: yupNumber().defined(), + last_event_at_millis: yupNumber().defined(), + chunk_count: yupNumber().defined(), + event_count: yupNumber().defined(), + }).defined(), + }), + async handler({ auth, params }) { + const prisma = await getPrismaClientForTenancy(auth.tenancy); + const schema = await getPrismaSchemaForTenancy(auth.tenancy); + const sessionReplayId = params.session_replay_id; + + const rows = await prisma.$queryRaw` + 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 + AND sr."id" = ${sessionReplayId} + LIMIT 1 + `; + + const row = rows.at(0); + if (row == null) { + throw new KnownErrors.ItemNotFound(sessionReplayId); + } + + const chunkAgg = (await prisma.sessionReplayChunk.groupBy({ + by: ["sessionReplayId"], + where: { + tenancyId: auth.tenancy.id, + sessionReplayId, + }, + _count: { _all: true }, + _sum: { eventCount: true }, + })).at(0); + + return { + statusCode: 200, + bodyType: "json", + body: { + id: row.id, + project_user: { + id: row.projectUserId, + display_name: row.projectUserDisplayName ?? null, + primary_email: row.primaryEmail ?? null, + }, + started_at_millis: row.startedAt.getTime(), + last_event_at_millis: row.lastEventAt.getTime(), + chunk_count: chunkAgg == null ? 0 : chunkAgg._count._all, + event_count: chunkAgg == null ? 0 : (chunkAgg._sum.eventCount ?? 0), + }, + }; + }, +}); From eca5155fc2174996393a2fb3352a8ad11f788667 Mon Sep 17 00:00:00 2001 From: Madison Date: Fri, 27 Mar 2026 13:02:43 -0500 Subject: [PATCH 06/13] Thin server page wrapper that awaits params.replayId and passes it to PageClient as initialReplayId --- .../[projectId]/analytics/replays/[replayId]/page.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/[replayId]/page.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/[replayId]/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/[replayId]/page.tsx new file mode 100644 index 0000000000..56eca8af2d --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/[replayId]/page.tsx @@ -0,0 +1,10 @@ +import PageClient from "../page-client"; + +export default async function Page(props: { + params: Promise<{ + replayId: string, + }>, +}) { + const params = await props.params; + return ; +} From 344c57f96af7752d441bec2476e5bd3ea38f20a1 Mon Sep 17 00:00:00 2001 From: Madison Date: Fri, 27 Mar 2026 13:12:21 -0500 Subject: [PATCH 07/13] Added ArrowLeftIcon and LinkIcon imports. Added initialReplayId prop and isStandaloneReplayPage derived flag. Added standalone replay fetching via adminApp.getSessionReplay() with loading/error state. Conditionally hides sidebar panel on standalone page. Added "Back to all replays" link under page title via PageLayout description prop. Added copy-link button to the header bar next to settings button. Changed viewer gate from selectedRecording to selectedRecordingId so standalone page can render before metadata loads. --- .../analytics/replays/page-client.tsx | 750 ++++++++++-------- 1 file changed, 415 insertions(+), 335 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx index c3c5de0fc5..acb2e6a9e4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx @@ -1,8 +1,10 @@ "use client"; +import { TeamSearchTable } from "@/components/data-table/team-search-table"; +import { UserSearchPicker } from "@/components/data-table/user-search-picker"; +import { StyledLink } from "@/components/link"; import { Alert, Button, Dialog, DialogContent, DialogHeader, DialogTitle, Skeleton, Switch, Typography } from "@/components/ui"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { StyledLink } from "@/components/link"; import { useFromNow } from "@/hooks/use-from-now"; import { getDesiredGlobalOffsetFromPlaybackState, @@ -16,26 +18,24 @@ import { NULL_TAB_KEY, } from "@/lib/session-replay-streams"; import { cn } from "@/lib/utils"; +import { ArrowLeftIcon, ArrowsClockwiseIcon, CursorClickIcon, FastForwardIcon, FunnelSimpleIcon, GearIcon, LinkIcon, MonitorPlayIcon, PauseIcon, PlayIcon, XIcon } from "@phosphor-icons/react"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; -import { ArrowsClockwiseIcon, CursorClickIcon, FastForwardIcon, FunnelSimpleIcon, GearIcon, MonitorPlayIcon, PauseIcon, PlayIcon, XIcon } from "@phosphor-icons/react"; -import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { UserSearchPicker } from "@/components/data-table/user-search-picker"; -import { TeamSearchTable } from "@/components/data-table/team-search-table"; +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 { + ALLOWED_PLAYER_SPEEDS, createInitialState, replayReducer, - ALLOWED_PLAYER_SPEEDS, - type ReplaySettings, - type ReplayState, + type ChunkRange, type ReplayAction, type ReplayEffect, + type ReplaySettings, + type ReplayState, type StreamInfo, - type ChunkRange, } from "./session-replay-machine"; const PAGE_SIZE = 50; @@ -87,6 +87,7 @@ type AdminAppWithSessionReplays = ReturnType & { items: RecordingRow[], nextCursor: string | null, }>, + getSessionReplay: (sessionReplayId: string) => Promise, getSessionReplayEvents: (sessionReplayId: string, options?: { offset?: number, limit?: number }) => Promise<{ chunks: ChunkRow[], chunkEvents: Array<{ chunkId: string, events: unknown[] }>, @@ -460,8 +461,13 @@ function useReplayMachine(initialSettings: ReplaySettings) { // Main component // --------------------------------------------------------------------------- -export default function PageClient() { +type PageClientProps = { + initialReplayId?: string, +}; + +export default function PageClient({ initialReplayId }: PageClientProps) { const adminApp = useAdminApp() as AdminAppWithSessionReplays; + const isStandaloneReplayPage = initialReplayId != null; // ---- Recording list + filters ---- @@ -475,13 +481,16 @@ export default function PageClient() { const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS); const [clickCountsByReplayId, setClickCountsByReplayId] = useState>(new Map()); const [timelineEvents, setTimelineEvents] = useState([]); + const [standaloneReplay, setStandaloneReplay] = useState(null); + const [standaloneReplayError, setStandaloneReplayError] = useState(null); const listBoxRef = useRef(null); - const [selectedRecordingId, setSelectedRecordingId] = useState(null); + const [selectedRecordingId, setSelectedRecordingId] = useState(initialReplayId ?? null); const selectedRecording = useMemo( - () => recordings.find(r => r.id === selectedRecordingId) ?? null, - [recordings, selectedRecordingId], + () => recordings.find(r => r.id === selectedRecordingId) + ?? (standaloneReplay?.id === selectedRecordingId ? standaloneReplay : null), + [recordings, selectedRecordingId, standaloneReplay], ); const hasAutoSelectedRef = useRef(false); @@ -533,13 +542,18 @@ export default function PageClient() { }, [adminApp, appliedFilters]); useEffect(() => { + if (isStandaloneReplayPage) { + setLoadingInitial(false); + return; + } setRecordings([]); setNextCursor(null); hasAutoSelectedRef.current = false; runAsynchronously(() => loadPage(null), { noErrorLogging: true }); - }, [loadPage]); + }, [isStandaloneReplayPage, loadPage]); useEffect(() => { + if (isStandaloneReplayPage) return; if (recordings.length === 0) return; const ids = recordings.map(r => r.id); runAsynchronously(async () => { @@ -559,7 +573,27 @@ export default function PageClient() { } setClickCountsByReplayId(map); }, { noErrorLogging: true }); - }, [recordings, adminApp]); + }, [isStandaloneReplayPage, recordings, adminApp]); + + useEffect(() => { + if (initialReplayId == null) return; + let cancelled = false; + setStandaloneReplay(null); + setStandaloneReplayError(null); + runAsynchronously(async () => { + try { + const replay = await adminApp.getSessionReplay(initialReplayId); + if (cancelled) return; + setStandaloneReplay(replay); + } catch (e: any) { + if (cancelled) return; + setStandaloneReplayError(e?.message ?? "Failed to load session replay."); + } + }, { noErrorLogging: true }); + return () => { + cancelled = true; + }; + }, [adminApp, initialReplayId, isStandaloneReplayPage]); const onListScroll = useCallback(() => { const el = listBoxRef.current; @@ -1133,9 +1167,10 @@ export default function PageClient() { }, [adminApp, msRef]); useEffect(() => { - if (!selectedRecordingId || !selectedRecording) return; + if (!selectedRecordingId) return; + if (!isStandaloneReplayPage && !selectedRecording) return; runAsynchronously(() => loadChunksAndDownload(selectedRecordingId), { noErrorLogging: true }); - }, [loadChunksAndDownload, selectedRecordingId, selectedRecording]); + }, [isStandaloneReplayPage, loadChunksAndDownload, selectedRecordingId, selectedRecording]); useEffect(() => { if (!selectedRecordingId) { @@ -1371,371 +1406,395 @@ export default function PageClient() { }, [draftFilters]); useEffect(() => { + if (isStandaloneReplayPage) return; if (recordings.length === 0) { setSelectedRecordingId(null); return; } if (selectedRecordingId && recordings.some((r) => r.id === selectedRecordingId)) return; setSelectedRecordingId(recordings[0]?.id ?? null); - }, [recordings, selectedRecordingId]); + }, [isStandaloneReplayPage, recordings, selectedRecordingId]); // ---- Rendering ---- return ( - + + + Back to all replays + + ) : undefined} + fillWidth + > - -
-
-
- - Sessions{!loadingInitial && recordings.length > 0 ? ` (${recordings.length}${nextCursor ? "+" : ""})` : ""} - - - - + + e.preventDefault()} + > + { requestAnimationFrame(() => openFilterDialog("user")); }}> + User + + { requestAnimationFrame(() => openFilterDialog("team")); }}> + Team + + { requestAnimationFrame(() => openFilterDialog("duration")); }}> + Duration + + { requestAnimationFrame(() => openFilterDialog("lastActive")); }}> + Last active + + { requestAnimationFrame(() => openFilterDialog("clicks")); }}> + Click count + + + +
+ + {activeFilterCount > 0 && ( +
+ {appliedFilters.userId && ( + + user:{appliedFilters.userLabel || "selected"} )} - - - e.preventDefault()} - > - { requestAnimationFrame(() => openFilterDialog("user")); }}> - User - - { requestAnimationFrame(() => openFilterDialog("team")); }}> - Team - - { requestAnimationFrame(() => openFilterDialog("duration")); }}> - Duration - - { requestAnimationFrame(() => openFilterDialog("lastActive")); }}> - Last active - - { requestAnimationFrame(() => openFilterDialog("clicks")); }}> - Click count - - - -
- - {activeFilterCount > 0 && ( -
- {appliedFilters.userId && ( - - user:{appliedFilters.userLabel || "selected"} - - )} - {appliedFilters.teamId && ( - - team:{appliedFilters.teamLabel || "selected"} - - )} - {(appliedFilters.durationMinSeconds || appliedFilters.durationMaxSeconds) && ( - - duration - - )} - {appliedFilters.lastActivePreset && ( - - last active: {appliedFilters.lastActivePreset} - - )} - {appliedFilters.clickCountMin && ( - - clicks - + {appliedFilters.teamId && ( + + team:{appliedFilters.teamLabel || "selected"} + + )} + {(appliedFilters.durationMinSeconds || appliedFilters.durationMaxSeconds) && ( + + duration + + )} + {appliedFilters.lastActivePreset && ( + + last active: {appliedFilters.lastActivePreset} + + )} + {appliedFilters.clickCountMin && ( + + clicks + + )} + +
)} -
- )} -
- setActiveFilterDialog(open ? "user" : null)}> - - - User Filter - -
- ( - - )} - /> -
- + )} + /> +
+ -
-
- -
- - setActiveFilterDialog(open ? "team" : null)}> - - - Team Filter - -
- ( - +
+ +
+
+ + setActiveFilterDialog(open ? "team" : null)}> + + + Team Filter + +
+ ( + - )} - /> -
- + )} + /> +
+ -
-
- -
- - setActiveFilterDialog(open ? "duration" : null)}> - - - Duration Filter - -
{ + }} + > + Clear + + + + +
+ + setActiveFilterDialog(open ? "duration" : null)}> + + + Duration Filter + + { e.preventDefault(); applyDraftFilters(); - }}> -
- - -
-
- - -
- -
-
- - setActiveFilterDialog(open ? "lastActive" : null)}> - - - Last Active Filter - -
- {([["24h", "Last 24 hours"], ["7d", "Last 7 days"], ["30d", "Last 30 days"]] as const).map(([value, label]) => ( - + +
+ +
+
+ + setActiveFilterDialog(open ? "lastActive" : null)}> + + + Last Active Filter + +
+ {([["24h", "Last 24 hours"], ["7d", "Last 7 days"], ["30d", "Last 30 days"]] as const).map(([value, label]) => ( + - ))} -
-
- + ))} +
+
+ -
-
-
- - setActiveFilterDialog(open ? "clicks" : null)}> - - - Click Count Filter - -
{ + }} + > + Clear + + + +
+ + setActiveFilterDialog(open ? "clicks" : null)}> + + + Click Count Filter + + { e.preventDefault(); applyDraftFilters(); - }}> -
- setDraftFilters((prev) => ({ ...prev, clickCountMin: e.target.value }))} - placeholder="Minimum click count" - /> -
-
- - -
- -
-
+ }}> +
+ setDraftFilters((prev) => ({ ...prev, clickCountMin: e.target.value }))} + placeholder="Minimum click count" + /> +
+
+ + +
+ + + - {listError && ( -
- {listError} -
- )} + {listError && ( +
+ {listError} +
+ )} -
- {loadingInitial ? ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
- - -
- ))} -
- ) : recordings.length === 0 ? ( -
- - {activeFilterCount > 0 ? "No replays match these filters." : "No replays yet."} - -
- ) : ( -
- {recordings.map((r) => { - const isSelected = r.id === selectedRecordingId; - const durationMs = r.lastEventAt.getTime() - r.startedAt.getTime(); - const duration = formatDurationMs(durationMs); - return ( - +
+ ); + })} + + {loadingMore && ( +
+ +
- - ); - })} - - {loadingMore && ( -
- - + )}
)}
- )} - - -
+ + - + + + )} - +
- {(ms.downloadError || ms.playerError) && ( + {(standaloneReplayError || ms.downloadError || ms.playerError) && (
+ {standaloneReplayError && {standaloneReplayError}} {ms.downloadError && {ms.downloadError}} {ms.playerError && {ms.playerError}}
@@ -1749,16 +1808,37 @@ export default function PageClient() { > {getRecordingTitle(selectedRecording)} + ) : isStandaloneReplayPage && selectedRecordingId ? ( + + Replay {selectedRecordingId} + ) : ( )} - actRef.current({ type: "UPDATE_SETTINGS", updates })} - /> +
+ {selectedRecordingId && ( + + )} + actRef.current({ type: "UPDATE_SETTINGS", updates })} + /> +
- {selectedRecording ? ( + {selectedRecordingId ? (
Date: Fri, 27 Mar 2026 13:13:31 -0500 Subject: [PATCH 08/13] Added 2 new tests: admin can fetch a single session replay by id. admin get session replay returns 404 for nonexistent id, and non-admin access cannot call single session replay endpoint. --- .../endpoints/api/v1/session-replays.test.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts index 0c699b5a1c..e3ab55637a 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts @@ -553,6 +553,140 @@ it("admin list session replays paginates without skipping items", async ({ expec expect(secondId).not.toBe(firstId); }); +it("admin can fetch a single session replay by id", async ({ expect }) => { + await Project.createAndSwitch({ config: { magic_link_enabled: true } }); + await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } }); + await Auth.Otp.signIn(); + + const upload = await uploadBatch({ + browserSessionId: randomUUID(), + batchId: randomUUID(), + startedAtMs: 1_700_000_000_000, + sentAtMs: 1_700_000_000_400, + events: [ + { type: 2, timestamp: 1_700_000_000_100 }, + { type: 3, timestamp: 1_700_000_000_250 }, + ], + }); + expect(upload.status).toBe(200); + const recordingId = upload.body?.session_replay_id; + expect(typeof recordingId).toBe("string"); + if (typeof recordingId !== "string") { + throw new Error("Expected session replay id."); + } + + const res = await niceBackendFetch(`/api/v1/internal/session-replays/${recordingId}`, { + method: "GET", + accessType: "admin", + }); + + expect(res).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": { + "chunk_count": 1, + "event_count": 2, + "id": "", + "last_event_at_millis": 1700000000250, + "project_user": { + "display_name": null, + "id": "", + "primary_email": "default-mailbox--@stack-generated.example.com", + }, + "started_at_millis": 1700000000100, + }, + "headers": Headers {