Merge branch 'dev' into promptless/document-requires-totp-mfa-jwt-claim

This commit is contained in:
promptless[bot] 2026-04-28 22:57:39 +00:00
commit 79d16f10c6
10 changed files with 793 additions and 393 deletions

View File

@ -0,0 +1,65 @@
import { Prisma } from "@/generated/prisma/client";
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy } 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";
import {
aggregateSessionReplayChunksByReplayIds,
querySessionReplayAdminRows,
sessionReplayAdminRowToApiItem,
} from "../session-replay-admin-rows";
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 querySessionReplayAdminRows({
prisma,
schema,
tenancyId: auth.tenancy.id,
suffixSql: Prisma.sql`AND sr."id" = ${sessionReplayId} LIMIT 1`,
});
const row = rows.at(0);
if (row == null) {
throw new KnownErrors.ItemNotFound(sessionReplayId);
}
const aggById = await aggregateSessionReplayChunksByReplayIds(prisma, auth.tenancy.id, [sessionReplayId]);
const agg = aggById.get(sessionReplayId) ?? { chunkCount: 0, eventCount: 0 };
return {
statusCode: 200,
bodyType: "json",
body: sessionReplayAdminRowToApiItem(row, agg),
};
},
});

View File

@ -1,6 +1,11 @@
import { getClickhouseExternalClient } from "@/lib/clickhouse";
import { Prisma } from "@/generated/prisma/client";
import { getClickhouseExternalClient } from "@/lib/clickhouse";
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client";
import {
aggregateSessionReplayChunksByReplayIds,
querySessionReplayAdminRows,
sessionReplayAdminRowToApiItem,
} from "./session-replay-admin-rows";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
@ -171,36 +176,7 @@ export const GET = createSmartRouteHandler({
}
}
type ReplayRow = {
id: string,
projectUserId: string,
startedAt: Date,
lastEventAt: Date,
projectUserDisplayName: string | null,
primaryEmail: string | 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
const suffixSql = Prisma.sql`
${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}
@ -221,27 +197,19 @@ export const GET = createSmartRouteHandler({
LIMIT ${limit + 1}
`;
const rows = await querySessionReplayAdminRows({
prisma,
schema,
tenancyId: auth.tenancy.id,
suffixSql,
});
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
const nextCursor = hasMore ? page[page.length - 1]!.id : null;
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 },
})
: [];
const aggBySessionId = new Map<string, { chunkCount: number, eventCount: number }>();
for (const a of chunkAggs) {
aggBySessionId.set(a.sessionReplayId, {
chunkCount: a._count._all,
eventCount: a._sum.eventCount ?? 0,
});
}
const aggBySessionId = await aggregateSessionReplayChunksByReplayIds(prisma, auth.tenancy.id, sessionIds);
return {
statusCode: 200,
@ -249,18 +217,7 @@ export const GET = createSmartRouteHandler({
body: {
items: page.map((row) => {
const agg = aggBySessionId.get(row.id) ?? { chunkCount: 0, eventCount: 0 };
return {
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: agg.chunkCount,
event_count: agg.eventCount,
};
return sessionReplayAdminRowToApiItem(row, agg);
}),
pagination: { next_cursor: nextCursor },
},

View File

@ -0,0 +1,92 @@
import { Prisma, PrismaClient } from "@/generated/prisma/client";
import { type PrismaClientWithReplica, sqlQuoteIdent } from "@/prisma-client";
/** Row shape from the admin session replay list / get SQL (SessionReplay + ProjectUser + primary email). */
export type SessionReplayAdminListRow = {
id: string,
projectUserId: string,
startedAt: Date,
lastEventAt: Date,
projectUserDisplayName: string | null,
primaryEmail: string | null,
};
export type SessionReplayChunkAgg = { chunkCount: number, eventCount: number };
/**
* Base query used by the internal session replay list and single-replay routes.
* `suffixSql` is everything after `WHERE sr."tenancyId" = …` (filters, ORDER BY, LIMIT).
*/
export async function querySessionReplayAdminRows(options: {
prisma: PrismaClientWithReplica<PrismaClient>,
schema: string,
tenancyId: string,
suffixSql: Prisma.Sql,
}): Promise<SessionReplayAdminListRow[]> {
const { prisma, schema, tenancyId, suffixSql } = options;
return await prisma.$queryRaw<SessionReplayAdminListRow[]>`
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" = ${tenancyId}::UUID
${suffixSql}
`;
}
export async function aggregateSessionReplayChunksByReplayIds(
prisma: PrismaClientWithReplica<PrismaClient>,
tenancyId: string,
sessionReplayIds: string[],
): Promise<Map<string, SessionReplayChunkAgg>> {
if (sessionReplayIds.length === 0) {
return new Map();
}
const chunkAggs = await prisma.sessionReplayChunk.groupBy({
by: ["sessionReplayId"],
where: { tenancyId, sessionReplayId: { in: sessionReplayIds } },
_count: { _all: true },
_sum: { eventCount: true },
});
const map = new Map<string, SessionReplayChunkAgg>();
for (const a of chunkAggs) {
map.set(a.sessionReplayId, {
chunkCount: a._count._all,
eventCount: a._sum.eventCount ?? 0,
});
}
return map;
}
export function sessionReplayAdminRowToApiItem(
row: SessionReplayAdminListRow,
agg: SessionReplayChunkAgg,
) {
return {
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: agg.chunkCount,
event_count: agg.eventCount,
};
}

View File

@ -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 <PageClient initialReplayId={params.replayId} />;
}

View File

@ -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 { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { ArrowLeftIcon, ArrowsClockwiseIcon, CheckIcon, CursorClickIcon, FastForwardIcon, FunnelSimpleIcon, GearIcon, LinkIcon, MonitorPlayIcon, PauseIcon, PlayIcon, XIcon } from "@phosphor-icons/react";
import { runAsynchronously, runAsynchronouslyWithAlert } 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<typeof useAdminApp> & {
items: RecordingRow[],
nextCursor: string | null,
}>,
getSessionReplay: (sessionReplayId: string) => Promise<RecordingRow>,
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,18 +481,38 @@ export default function PageClient() {
const [draftFilters, setDraftFilters] = useState<ReplayFilters>(EMPTY_FILTERS);
const [clickCountsByReplayId, setClickCountsByReplayId] = useState<Map<string, number>>(new Map());
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>([]);
const [standaloneReplay, setStandaloneReplay] = useState<RecordingRow | null>(null);
const [standaloneReplayError, setStandaloneReplayError] = useState<string | null>(null);
const listBoxRef = useRef<HTMLDivElement | null>(null);
const [selectedRecordingId, setSelectedRecordingId] = useState<string | null>(null);
const [selectedRecordingId, setSelectedRecordingId] = useState<string | null>(initialReplayId ?? null);
const [replayShareLinkCopied, setReplayShareLinkCopied] = useState(false);
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);
const loadingMoreRef = useRef(false);
useEffect(() => {
if (!isStandaloneReplayPage) return;
if (selectedRecordingId === initialReplayId && (standaloneReplay == null || standaloneReplay.id === initialReplayId)) return;
setSelectedRecordingId(initialReplayId);
}, [initialReplayId, isStandaloneReplayPage, selectedRecordingId, standaloneReplay, setSelectedRecordingId]);
useEffect(() => {
setReplayShareLinkCopied(false);
}, [selectedRecordingId]);
useEffect(() => {
if (!replayShareLinkCopied) return;
const timer = setTimeout(() => setReplayShareLinkCopied(false), 2000);
return () => clearTimeout(timer);
}, [replayShareLinkCopied]);
const loadPage = useCallback(async (cursor: string | null) => {
if (cursor !== null && loadingMoreRef.current) return;
@ -533,13 +559,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 +590,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 +1184,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 +1423,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 (
<AppEnabledGuard appId="analytics">
<PageLayout title="Session Replays" fillWidth>
<PageLayout
title={isStandaloneReplayPage ? "Session Replay" : "Session Replays"}
description={isStandaloneReplayPage ? (
<StyledLink
href={`/projects/${encodeURIComponent(adminApp.projectId)}/analytics/replays`}
className="inline-flex items-center gap-1 text-xs"
>
<ArrowLeftIcon className="h-3 w-3" />
Back to all replays
</StyledLink>
) : undefined}
fillWidth
>
<PanelGroup data-walkthrough="analytics-replays" direction="horizontal" className="!h-[calc(100vh-180px)] min-h-[520px] rounded-xl border border-border/40 overflow-hidden bg-background">
<Panel defaultSize={25} minSize={16}>
<div className="h-full flex flex-col">
<div className="shrink-0 px-3 py-2 border-b border-border/30 space-y-2">
<div className="flex items-center justify-between gap-2 h-8">
<Typography className="text-sm font-medium">
Sessions{!loadingInitial && recordings.length > 0 ? ` (${recordings.length}${nextCursor ? "+" : ""})` : ""}
</Typography>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 px-2.5"
>
<FunnelSimpleIcon className="h-3.5 w-3.5 mr-1" />
Filters
{activeFilterCount > 0 && (
<span className="ml-1 rounded-full bg-foreground/10 px-1.5 py-0 text-[10px]">
{activeFilterCount}
{!isStandaloneReplayPage && (
<>
<Panel defaultSize={25} minSize={16}>
<div className="h-full flex flex-col">
<div className="shrink-0 px-3 py-2 border-b border-border/30 space-y-2">
<div className="flex items-center justify-between gap-2 h-8">
<Typography className="text-sm font-medium">
Sessions{!loadingInitial && recordings.length > 0 ? ` (${recordings.length}${nextCursor ? "+" : ""})` : ""}
</Typography>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 px-2.5"
>
<FunnelSimpleIcon className="h-3.5 w-3.5 mr-1" />
Filters
{activeFilterCount > 0 && (
<span className="ml-1 rounded-full bg-foreground/10 px-1.5 py-0 text-[10px]">
{activeFilterCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("user")); }}>
User
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("team")); }}>
Team
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("duration")); }}>
Duration
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("lastActive")); }}>
Last active
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("clicks")); }}>
Click count
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{activeFilterCount > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{appliedFilters.userId && (
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
user:{appliedFilters.userLabel || "selected"}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("user")); }}>
User
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("team")); }}>
Team
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("duration")); }}>
Duration
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("lastActive")); }}>
Last active
</DropdownMenuItem>
<DropdownMenuItem onClick={() => { requestAnimationFrame(() => openFilterDialog("clicks")); }}>
Click count
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{activeFilterCount > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{appliedFilters.userId && (
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
user:{appliedFilters.userLabel || "selected"}
</span>
{appliedFilters.teamId && (
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
team:{appliedFilters.teamLabel || "selected"}
</span>
)}
{(appliedFilters.durationMinSeconds || appliedFilters.durationMaxSeconds) && (
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
duration
</span>
)}
{appliedFilters.lastActivePreset && (
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
last active: {appliedFilters.lastActivePreset}
</span>
)}
{appliedFilters.clickCountMin && (
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
clicks
</span>
)}
<button
className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px] text-muted-foreground hover:text-foreground transition-colors hover:transition-none"
onClick={() => setAppliedFilters(EMPTY_FILTERS)}
>
<XIcon className="h-2.5 w-2.5" />
clear
</button>
</div>
)}
{appliedFilters.teamId && (
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
team:{appliedFilters.teamLabel || "selected"}
</span>
)}
{(appliedFilters.durationMinSeconds || appliedFilters.durationMaxSeconds) && (
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
duration
</span>
)}
{appliedFilters.lastActivePreset && (
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
last active: {appliedFilters.lastActivePreset}
</span>
)}
{appliedFilters.clickCountMin && (
<span className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px]">
clicks
</span>
)}
<button
className="inline-flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[10px] text-muted-foreground hover:text-foreground transition-colors hover:transition-none"
onClick={() => setAppliedFilters(EMPTY_FILTERS)}
>
<XIcon className="h-2.5 w-2.5" />
clear
</button>
</div>
)}
</div>
<Dialog open={activeFilterDialog === "user"} onOpenChange={(open) => setActiveFilterDialog(open ? "user" : null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>User Filter</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<UserSearchPicker
action={(user) => (
<Button
variant="outline"
size="sm"
onClick={() => {
<Dialog open={activeFilterDialog === "user"} onOpenChange={(open) => setActiveFilterDialog(open ? "user" : null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>User Filter</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<UserSearchPicker
action={(user) => (
<Button
variant="outline"
size="sm"
onClick={() => {
setAppliedFilters((prev) => ({
...prev,
userId: user.id,
userLabel: user.displayName ?? user.primaryEmail ?? user.id,
}));
setActiveFilterDialog(null);
}}
>
Select
</Button>
)}
/>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
}}
>
Select
</Button>
)}
/>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
setAppliedFilters((prev) => ({ ...prev, userId: "", userLabel: "" }));
setActiveFilterDialog(null);
}}
>
Clear
</Button>
</div>
</div>
</DialogContent>
</Dialog>
}}
>
Clear
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={activeFilterDialog === "team"} onOpenChange={(open) => setActiveFilterDialog(open ? "team" : null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Team Filter</DialogTitle>
</DialogHeader>
<div className="space-y-3 pt-2">
<TeamSearchTable
action={(team) => (
<Button
variant="outline"
size="sm"
onClick={() => {
<Dialog open={activeFilterDialog === "team"} onOpenChange={(open) => setActiveFilterDialog(open ? "team" : null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Team Filter</DialogTitle>
</DialogHeader>
<div className="space-y-3 pt-2">
<TeamSearchTable
action={(team) => (
<Button
variant="outline"
size="sm"
onClick={() => {
setAppliedFilters((prev) => ({
...prev,
teamId: team.id,
teamLabel: team.displayName,
}));
setActiveFilterDialog(null);
}}
>
Select
</Button>
)}
/>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
}}
>
Select
</Button>
)}
/>
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
setAppliedFilters((prev) => ({ ...prev, teamId: "", teamLabel: "" }));
setActiveFilterDialog(null);
}}
>
Clear
</Button>
</div>
</div>
</DialogContent>
</Dialog>
}}
>
Clear
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={activeFilterDialog === "duration"} onOpenChange={(open) => setActiveFilterDialog(open ? "duration" : null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Duration Filter</DialogTitle>
</DialogHeader>
<form onSubmit={(e) => {
<Dialog open={activeFilterDialog === "duration"} onOpenChange={(open) => setActiveFilterDialog(open ? "duration" : null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Duration Filter</DialogTitle>
</DialogHeader>
<form onSubmit={(e) => {
e.preventDefault();
applyDraftFilters();
}}>
<div className="grid grid-cols-2 gap-3 pt-2">
<label className="space-y-1">
<Typography className="text-xs text-muted-foreground">Min (seconds)</Typography>
<input
type="number"
min={0}
className="h-8 w-full rounded-md border border-border/50 bg-background px-2 text-xs"
value={draftFilters.durationMinSeconds}
onChange={(e) => setDraftFilters((prev) => ({ ...prev, durationMinSeconds: e.target.value }))}
/>
</label>
<label className="space-y-1">
<Typography className="text-xs text-muted-foreground">Max (seconds)</Typography>
<input
type="number"
min={0}
className="h-8 w-full rounded-md border border-border/50 bg-background px-2 text-xs"
value={draftFilters.durationMaxSeconds}
onChange={(e) => setDraftFilters((prev) => ({ ...prev, durationMaxSeconds: e.target.value }))}
/>
</label>
</div>
<div className="pt-3 flex items-center justify-end gap-2">
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={() => setActiveFilterDialog(null)}>Cancel</Button>
<Button type="submit" size="sm" className="h-8">Apply</Button>
</div>
</form>
</DialogContent>
</Dialog>
}}>
<div className="grid grid-cols-2 gap-3 pt-2">
<label className="space-y-1">
<Typography className="text-xs text-muted-foreground">Min (seconds)</Typography>
<input
type="number"
min={0}
className="h-8 w-full rounded-md border border-border/50 bg-background px-2 text-xs"
value={draftFilters.durationMinSeconds}
onChange={(e) => setDraftFilters((prev) => ({ ...prev, durationMinSeconds: e.target.value }))}
/>
</label>
<label className="space-y-1">
<Typography className="text-xs text-muted-foreground">Max (seconds)</Typography>
<input
type="number"
min={0}
className="h-8 w-full rounded-md border border-border/50 bg-background px-2 text-xs"
value={draftFilters.durationMaxSeconds}
onChange={(e) => setDraftFilters((prev) => ({ ...prev, durationMaxSeconds: e.target.value }))}
/>
</label>
</div>
<div className="pt-3 flex items-center justify-end gap-2">
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={() => setActiveFilterDialog(null)}>Cancel</Button>
<Button type="submit" size="sm" className="h-8">Apply</Button>
</div>
</form>
</DialogContent>
</Dialog>
<Dialog open={activeFilterDialog === "lastActive"} onOpenChange={(open) => setActiveFilterDialog(open ? "lastActive" : null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Last Active Filter</DialogTitle>
</DialogHeader>
<div className="flex flex-wrap gap-2 pt-2">
{([["24h", "Last 24 hours"], ["7d", "Last 7 days"], ["30d", "Last 30 days"]] as const).map(([value, label]) => (
<Button
key={value}
variant={appliedFilters.lastActivePreset === value ? "default" : "outline"}
size="sm"
className="h-8"
onClick={() => {
<Dialog open={activeFilterDialog === "lastActive"} onOpenChange={(open) => setActiveFilterDialog(open ? "lastActive" : null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Last Active Filter</DialogTitle>
</DialogHeader>
<div className="flex flex-wrap gap-2 pt-2">
{([["24h", "Last 24 hours"], ["7d", "Last 7 days"], ["30d", "Last 30 days"]] as const).map(([value, label]) => (
<Button
key={value}
variant={appliedFilters.lastActivePreset === value ? "default" : "outline"}
size="sm"
className="h-8"
onClick={() => {
setAppliedFilters((prev) => ({ ...prev, lastActivePreset: value }));
setActiveFilterDialog(null);
}}
>
{label}
</Button>
))}
</div>
<div className="pt-1 flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
}}
>
{label}
</Button>
))}
</div>
<div className="pt-1 flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => {
setAppliedFilters((prev) => ({ ...prev, lastActivePreset: "" }));
setActiveFilterDialog(null);
}}
>
Clear
</Button>
</div>
</DialogContent>
</Dialog>
}}
>
Clear
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={activeFilterDialog === "clicks"} onOpenChange={(open) => setActiveFilterDialog(open ? "clicks" : null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Click Count Filter</DialogTitle>
</DialogHeader>
<form onSubmit={(e) => {
<Dialog open={activeFilterDialog === "clicks"} onOpenChange={(open) => setActiveFilterDialog(open ? "clicks" : null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Click Count Filter</DialogTitle>
</DialogHeader>
<form onSubmit={(e) => {
e.preventDefault();
applyDraftFilters();
}}>
<div className="space-y-3 pt-2">
<input
type="number"
min={0}
className="h-8 w-full rounded-md border border-border/50 bg-background px-2 text-xs"
value={draftFilters.clickCountMin}
onChange={(e) => setDraftFilters((prev) => ({ ...prev, clickCountMin: e.target.value }))}
placeholder="Minimum click count"
/>
</div>
<div className="pt-3 flex items-center justify-end gap-2">
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={() => setActiveFilterDialog(null)}>Cancel</Button>
<Button type="submit" size="sm" className="h-8">Apply</Button>
</div>
</form>
</DialogContent>
</Dialog>
}}>
<div className="space-y-3 pt-2">
<input
type="number"
min={0}
className="h-8 w-full rounded-md border border-border/50 bg-background px-2 text-xs"
value={draftFilters.clickCountMin}
onChange={(e) => setDraftFilters((prev) => ({ ...prev, clickCountMin: e.target.value }))}
placeholder="Minimum click count"
/>
</div>
<div className="pt-3 flex items-center justify-end gap-2">
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={() => setActiveFilterDialog(null)}>Cancel</Button>
<Button type="submit" size="sm" className="h-8">Apply</Button>
</div>
</form>
</DialogContent>
</Dialog>
{listError && (
<div className="p-3">
<Alert variant="destructive">{listError}</Alert>
</div>
)}
{listError && (
<div className="p-3">
<Alert variant="destructive">{listError}</Alert>
</div>
)}
<div
ref={listBoxRef}
onScroll={onListScroll}
className="flex-1 overflow-y-auto"
>
{loadingInitial ? (
<div className="p-2 space-y-1">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-lg p-3">
<Skeleton className="h-4 w-36" />
<Skeleton className="mt-1.5 h-3 w-24" />
<div
ref={listBoxRef}
onScroll={onListScroll}
className="flex-1 overflow-y-auto"
>
{loadingInitial ? (
<div className="p-2 space-y-1">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="rounded-lg p-3">
<Skeleton className="h-4 w-36" />
<Skeleton className="mt-1.5 h-3 w-24" />
</div>
))}
</div>
))}
</div>
) : recordings.length === 0 ? (
<div className="p-6 text-center">
<Typography className="text-sm text-muted-foreground">
{activeFilterCount > 0 ? "No replays match these filters." : "No replays yet."}
</Typography>
</div>
) : (
<div className="p-1.5 space-y-0.5">
{recordings.map((r) => {
const isSelected = r.id === selectedRecordingId;
const durationMs = r.lastEventAt.getTime() - r.startedAt.getTime();
const duration = formatDurationMs(durationMs);
return (
<button
key={r.id}
onClick={() => setSelectedRecordingId(r.id)}
className={cn(
"w-full text-left rounded-lg px-3 py-2.5",
"transition-colors hover:transition-none",
isSelected ? "bg-muted/60 ring-1 ring-border/40" : "hover:bg-muted/20",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">
{getRecordingTitle(r)}
</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{duration}
</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<DisplayDate date={r.lastEventAt} />
{(clickCountsByReplayId.get(r.id) ?? 0) > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/70">
<CursorClickIcon className="h-3 w-3" />
{clickCountsByReplayId.get(r.id)}
</span>
)}
</div>
</button>
);
})}
) : recordings.length === 0 ? (
<div className="p-6 text-center">
<Typography className="text-sm text-muted-foreground">
{activeFilterCount > 0 ? "No replays match these filters." : "No replays yet."}
</Typography>
</div>
) : (
<div className="p-1.5 space-y-0.5">
{recordings.map((r) => {
const isSelected = r.id === selectedRecordingId;
const durationMs = r.lastEventAt.getTime() - r.startedAt.getTime();
const duration = formatDurationMs(durationMs);
return (
<div
key={r.id}
className={cn(
"rounded-lg",
isSelected ? "bg-muted/60 ring-1 ring-border/40" : "hover:bg-muted/20",
)}
>
<button
onClick={() => setSelectedRecordingId(r.id)}
className={cn(
"w-full text-left rounded-lg px-3 py-2.5",
"transition-colors hover:transition-none",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium truncate">
{getRecordingTitle(r)}
</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{duration}
</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<DisplayDate date={r.lastEventAt} />
{(clickCountsByReplayId.get(r.id) ?? 0) > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground/70">
<CursorClickIcon className="h-3 w-3" />
{clickCountsByReplayId.get(r.id)}
</span>
)}
</div>
</button>
</div>
);
})}
{loadingMore && (
<div className="rounded-lg p-3">
<Skeleton className="h-4 w-36" />
<Skeleton className="mt-1.5 h-3 w-24" />
{loadingMore && (
<div className="rounded-lg p-3">
<Skeleton className="h-4 w-36" />
<Skeleton className="mt-1.5 h-3 w-24" />
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
</Panel>
</div>
</Panel>
<PanelResizeHandle className="w-px bg-border/40 hover:bg-border transition-colors hover:transition-none" />
<PanelResizeHandle className="w-px bg-border/40 hover:bg-border transition-colors hover:transition-none" />
</>
)}
<Panel defaultSize={75} minSize={35}>
<Panel defaultSize={isStandaloneReplayPage ? 100 : 75} minSize={35}>
<div className="h-full flex flex-col">
{(ms.downloadError || ms.playerError) && (
{(standaloneReplayError || ms.downloadError || ms.playerError) && (
<div className="p-3 space-y-2">
{standaloneReplayError && <Alert variant="destructive">{standaloneReplayError}</Alert>}
{ms.downloadError && <Alert variant="destructive">{ms.downloadError}</Alert>}
{ms.playerError && <Alert variant="destructive">{ms.playerError}</Alert>}
</div>
@ -1749,16 +1825,43 @@ export default function PageClient() {
>
{getRecordingTitle(selectedRecording)}
</StyledLink>
) : isStandaloneReplayPage && selectedRecordingId ? (
<Typography className="text-sm font-medium truncate font-mono">
Replay {selectedRecordingId}
</Typography>
) : (
<Typography className="text-sm font-medium truncate" />
)}
<ReplaySettingsButton
settings={ms.settings}
onSettingsChange={(updates) => actRef.current({ type: "UPDATE_SETTINGS", updates })}
/>
<div className="flex items-center gap-1">
{selectedRecordingId && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={replayShareLinkCopied ? "Link copied" : "Copy link to replay"}
title={replayShareLinkCopied ? "Copied!" : "Copy link to replay"}
onClick={() => runAsynchronouslyWithAlert(async () => {
await navigator.clipboard.writeText(
`${window.location.origin}/projects/${encodeURIComponent(adminApp.projectId)}/analytics/replays/${encodeURIComponent(selectedRecordingId)}`,
);
setReplayShareLinkCopied(true);
})}
>
{replayShareLinkCopied ? (
<CheckIcon className="h-4 w-4 text-emerald-600" weight="bold" />
) : (
<LinkIcon className="h-4 w-4" />
)}
</Button>
)}
<ReplaySettingsButton
settings={ms.settings}
onSettingsChange={(updates) => actRef.current({ type: "UPDATE_SETTINGS", updates })}
/>
</div>
</div>
{selectedRecording ? (
{selectedRecordingId ? (
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex-1 overflow-hidden flex flex-col">
<div

View File

@ -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": "<stripped UUID>",
"last_event_at_millis": 1700000000250,
"project_user": {
"display_name": null,
"id": "<stripped UUID>",
"primary_email": "default-mailbox--<stripped UUID>@stack-generated.example.com",
},
"started_at_millis": 1700000000100,
},
"headers": Headers { <some fields may have been hidden> },
}
`);
});
it("admin get session replay returns 404 for nonexistent id", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Auth.Otp.signIn();
const fakeId = randomUUID();
const res = await niceBackendFetch(`/api/v1/internal/session-replays/${fakeId}`, {
method: "GET",
accessType: "admin",
});
expect(res).toMatchInlineSnapshot(`
NiceResponse {
"status": 404,
"body": {
"code": "ITEM_NOT_FOUND",
"details": { "item_id": "<stripped UUID>" },
"error": "Item with ID \\"<stripped UUID>\\" not found.",
},
"headers": Headers {
"x-stack-known-error": "ITEM_NOT_FOUND",
<some fields may have been hidden>,
},
}
`);
});
it("non-admin access cannot call single session replay endpoint", 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: 1, timestamp: 1_700_000_000_100 }],
});
expect(upload.status).toBe(200);
const recordingId = upload.body?.session_replay_id;
expect(typeof recordingId).toBe("string");
const clientRes = await niceBackendFetch(`/api/v1/internal/session-replays/${recordingId}`, {
method: "GET",
accessType: "client",
});
expect(clientRes).toMatchInlineSnapshot(`
NiceResponse {
"status": 401,
"body": {
"code": "INSUFFICIENT_ACCESS_TYPE",
"details": {
"actual_access_type": "client",
"allowed_access_types": ["admin"],
},
"error": "The x-stack-access-type header must be 'admin', but was 'client'.",
},
"headers": Headers {
"x-stack-known-error": "INSUFFICIENT_ACCESS_TYPE",
<some fields may have been hidden>,
},
}
`);
const serverRes = await niceBackendFetch(`/api/v1/internal/session-replays/${recordingId}`, {
method: "GET",
accessType: "server",
});
expect(serverRes).toMatchInlineSnapshot(`
NiceResponse {
"status": 401,
"body": {
"code": "INSUFFICIENT_ACCESS_TYPE",
"details": {
"actual_access_type": "server",
"allowed_access_types": ["admin"],
},
"error": "The x-stack-access-type header must be 'admin', but was 'server'.",
},
"headers": Headers {
"x-stack-known-error": "INSUFFICIENT_ACCESS_TYPE",
<some fields may have been hidden>,
},
}
`);
});
it("admin list session replays rejects unknown cursor", async ({ expect }) => {
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
await Auth.Otp.signIn();

View File

@ -13,6 +13,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,
@ -835,6 +836,15 @@ export class StackAdminInterface extends StackServerInterface {
return await response.json();
}
async getSessionReplay(sessionReplayId: string): Promise<AdminGetSessionReplayResponse> {
const response = await this.sendAdminRequest(
`/internal/session-replays/${encodeURIComponent(sessionReplayId)}`,
{ method: "GET" },
null,
);
return await response.json();
}
async listSessionReplayChunks(sessionReplayId: string, params?: AdminListSessionReplayChunksOptions): Promise<AdminListSessionReplayChunksResponse> {
const qs = new URLSearchParams();
if (params?.cursor) qs.set("cursor", params.cursor);

View File

@ -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,

View File

@ -33,7 +33,6 @@ import { PushedConfigSource } from "../../projects";
import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like
type BranchConfigSourceApi = yup.InferType<typeof branchConfigSourceSchema>;
/**
* Converts a PushedConfigSource (SDK camelCase) to BranchConfigSourceApi (API snake_case).
*/
@ -1146,6 +1145,22 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
};
}
async getSessionReplay(sessionReplayId: string): Promise<AdminSessionReplay> {
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<ListSessionReplayChunksResult> {
const response = await this._interface.listSessionReplayChunks(sessionReplayId, {
cursor: options?.cursor,

View File

@ -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<HasTokenStore extends boolean = boolean, ProjectId ext
queryAnalytics(options: AnalyticsQueryOptions): Promise<AnalyticsQueryResponse>,
listSessionReplays(options?: ListSessionReplaysOptions): Promise<ListSessionReplaysResult>,
getSessionReplay(sessionReplayId: string): Promise<AdminSessionReplay>,
listSessionReplayChunks(sessionReplayId: string, options?: ListSessionReplayChunksOptions): Promise<ListSessionReplayChunksResult>,
getSessionReplayChunkEvents(sessionReplayId: string, chunkId: string): Promise<AdminGetSessionReplayChunkEventsResponse>,
getSessionReplayEvents(sessionReplayId: string, options?: { offset?: number, limit?: number }): Promise<SessionReplayAllEventsResult>,