Skip to content

Commit 71640b1

Browse files
authored
Merge branch 'dev' into apps/support
2 parents e768468 + ed89610 commit 71640b1

36 files changed

Lines changed: 1566 additions & 526 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Prisma } from "@/generated/prisma/client";
2+
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy } from "@/prisma-client";
3+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
4+
import { KnownErrors } from "@stackframe/stack-shared";
5+
import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
6+
import {
7+
aggregateSessionReplayChunksByReplayIds,
8+
querySessionReplayAdminRows,
9+
sessionReplayAdminRowToApiItem,
10+
} from "../session-replay-admin-rows";
11+
12+
export const GET = createSmartRouteHandler({
13+
metadata: { hidden: true },
14+
request: yupObject({
15+
auth: yupObject({
16+
type: adminAuthTypeSchema.defined(),
17+
tenancy: adaptSchema.defined(),
18+
}).defined(),
19+
params: yupObject({
20+
session_replay_id: yupString().defined(),
21+
}).defined(),
22+
}),
23+
response: yupObject({
24+
statusCode: yupNumber().oneOf([200]).defined(),
25+
bodyType: yupString().oneOf(["json"]).defined(),
26+
body: yupObject({
27+
id: yupString().defined(),
28+
project_user: yupObject({
29+
id: yupString().defined(),
30+
display_name: yupString().nullable().defined(),
31+
primary_email: yupString().nullable().defined(),
32+
}).defined(),
33+
started_at_millis: yupNumber().defined(),
34+
last_event_at_millis: yupNumber().defined(),
35+
chunk_count: yupNumber().defined(),
36+
event_count: yupNumber().defined(),
37+
}).defined(),
38+
}),
39+
async handler({ auth, params }) {
40+
const prisma = await getPrismaClientForTenancy(auth.tenancy);
41+
const schema = await getPrismaSchemaForTenancy(auth.tenancy);
42+
const sessionReplayId = params.session_replay_id;
43+
44+
const rows = await querySessionReplayAdminRows({
45+
prisma,
46+
schema,
47+
tenancyId: auth.tenancy.id,
48+
suffixSql: Prisma.sql`AND sr."id" = ${sessionReplayId} LIMIT 1`,
49+
});
50+
51+
const row = rows.at(0);
52+
if (row == null) {
53+
throw new KnownErrors.ItemNotFound(sessionReplayId);
54+
}
55+
56+
const aggById = await aggregateSessionReplayChunksByReplayIds(prisma, auth.tenancy.id, [sessionReplayId]);
57+
const agg = aggById.get(sessionReplayId) ?? { chunkCount: 0, eventCount: 0 };
58+
59+
return {
60+
statusCode: 200,
61+
bodyType: "json",
62+
body: sessionReplayAdminRowToApiItem(row, agg),
63+
};
64+
},
65+
});

apps/backend/src/app/api/latest/internal/session-replays/route.tsx

Lines changed: 16 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
import { getClickhouseExternalClient } from "@/lib/clickhouse";
21
import { Prisma } from "@/generated/prisma/client";
2+
import { getClickhouseExternalClient } from "@/lib/clickhouse";
33
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client";
4+
import {
5+
aggregateSessionReplayChunksByReplayIds,
6+
querySessionReplayAdminRows,
7+
sessionReplayAdminRowToApiItem,
8+
} from "./session-replay-admin-rows";
49
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
510
import { KnownErrors } from "@stackframe/stack-shared";
611
import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
@@ -171,36 +176,7 @@ export const GET = createSmartRouteHandler({
171176
}
172177
}
173178

174-
type ReplayRow = {
175-
id: string,
176-
projectUserId: string,
177-
startedAt: Date,
178-
lastEventAt: Date,
179-
projectUserDisplayName: string | null,
180-
primaryEmail: string | null,
181-
};
182-
183-
const rows = await prisma.$queryRaw<ReplayRow[]>`
184-
SELECT
185-
sr."id",
186-
sr."projectUserId",
187-
sr."startedAt",
188-
sr."lastEventAt",
189-
pu."displayName" AS "projectUserDisplayName",
190-
(
191-
SELECT cc."value"
192-
FROM ${sqlQuoteIdent(schema)}."ContactChannel" cc
193-
WHERE cc."projectUserId" = sr."projectUserId"
194-
AND cc."tenancyId" = sr."tenancyId"
195-
AND cc."type" = 'EMAIL'
196-
AND cc."isPrimary" = 'TRUE'::"BooleanTrue"
197-
LIMIT 1
198-
) AS "primaryEmail"
199-
FROM ${sqlQuoteIdent(schema)}."SessionReplay" sr
200-
JOIN ${sqlQuoteIdent(schema)}."ProjectUser" pu
201-
ON pu."projectUserId" = sr."projectUserId"
202-
AND pu."tenancyId" = sr."tenancyId"
203-
WHERE sr."tenancyId" = ${auth.tenancy.id}::UUID
179+
const suffixSql = Prisma.sql`
204180
${userIdsFilter.length > 0 ? Prisma.sql`AND sr."projectUserId" IN (${Prisma.join(userIdsFilter)})` : Prisma.empty}
205181
${lastEventAtFrom ? Prisma.sql`AND sr."lastEventAt" >= ${lastEventAtFrom}` : Prisma.empty}
206182
${lastEventAtTo ? Prisma.sql`AND sr."lastEventAt" <= ${lastEventAtTo}` : Prisma.empty}
@@ -221,46 +197,27 @@ export const GET = createSmartRouteHandler({
221197
LIMIT ${limit + 1}
222198
`;
223199

200+
const rows = await querySessionReplayAdminRows({
201+
prisma,
202+
schema,
203+
tenancyId: auth.tenancy.id,
204+
suffixSql,
205+
});
206+
224207
const hasMore = rows.length > limit;
225208
const page = hasMore ? rows.slice(0, limit) : rows;
226209
const nextCursor = hasMore ? page[page.length - 1]!.id : null;
227210

228211
const sessionIds = page.map((row) => row.id);
229-
const chunkAggs = sessionIds.length
230-
? await prisma.sessionReplayChunk.groupBy({
231-
by: ["sessionReplayId"],
232-
where: { tenancyId: auth.tenancy.id, sessionReplayId: { in: sessionIds } },
233-
_count: { _all: true },
234-
_sum: { eventCount: true },
235-
})
236-
: [];
237-
238-
const aggBySessionId = new Map<string, { chunkCount: number, eventCount: number }>();
239-
for (const a of chunkAggs) {
240-
aggBySessionId.set(a.sessionReplayId, {
241-
chunkCount: a._count._all,
242-
eventCount: a._sum.eventCount ?? 0,
243-
});
244-
}
212+
const aggBySessionId = await aggregateSessionReplayChunksByReplayIds(prisma, auth.tenancy.id, sessionIds);
245213

246214
return {
247215
statusCode: 200,
248216
bodyType: "json",
249217
body: {
250218
items: page.map((row) => {
251219
const agg = aggBySessionId.get(row.id) ?? { chunkCount: 0, eventCount: 0 };
252-
return {
253-
id: row.id,
254-
project_user: {
255-
id: row.projectUserId,
256-
display_name: row.projectUserDisplayName ?? null,
257-
primary_email: row.primaryEmail ?? null,
258-
},
259-
started_at_millis: row.startedAt.getTime(),
260-
last_event_at_millis: row.lastEventAt.getTime(),
261-
chunk_count: agg.chunkCount,
262-
event_count: agg.eventCount,
263-
};
220+
return sessionReplayAdminRowToApiItem(row, agg);
264221
}),
265222
pagination: { next_cursor: nextCursor },
266223
},
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Prisma, PrismaClient } from "@/generated/prisma/client";
2+
import { type PrismaClientWithReplica, sqlQuoteIdent } from "@/prisma-client";
3+
4+
/** Row shape from the admin session replay list / get SQL (SessionReplay + ProjectUser + primary email). */
5+
export type SessionReplayAdminListRow = {
6+
id: string,
7+
projectUserId: string,
8+
startedAt: Date,
9+
lastEventAt: Date,
10+
projectUserDisplayName: string | null,
11+
primaryEmail: string | null,
12+
};
13+
14+
export type SessionReplayChunkAgg = { chunkCount: number, eventCount: number };
15+
16+
/**
17+
* Base query used by the internal session replay list and single-replay routes.
18+
* `suffixSql` is everything after `WHERE sr."tenancyId" = …` (filters, ORDER BY, LIMIT).
19+
*/
20+
export async function querySessionReplayAdminRows(options: {
21+
prisma: PrismaClientWithReplica<PrismaClient>,
22+
schema: string,
23+
tenancyId: string,
24+
suffixSql: Prisma.Sql,
25+
}): Promise<SessionReplayAdminListRow[]> {
26+
const { prisma, schema, tenancyId, suffixSql } = options;
27+
return await prisma.$queryRaw<SessionReplayAdminListRow[]>`
28+
SELECT
29+
sr."id",
30+
sr."projectUserId",
31+
sr."startedAt",
32+
sr."lastEventAt",
33+
pu."displayName" AS "projectUserDisplayName",
34+
(
35+
SELECT cc."value"
36+
FROM ${sqlQuoteIdent(schema)}."ContactChannel" cc
37+
WHERE cc."projectUserId" = sr."projectUserId"
38+
AND cc."tenancyId" = sr."tenancyId"
39+
AND cc."type" = 'EMAIL'
40+
AND cc."isPrimary" = 'TRUE'::"BooleanTrue"
41+
LIMIT 1
42+
) AS "primaryEmail"
43+
FROM ${sqlQuoteIdent(schema)}."SessionReplay" sr
44+
JOIN ${sqlQuoteIdent(schema)}."ProjectUser" pu
45+
ON pu."projectUserId" = sr."projectUserId"
46+
AND pu."tenancyId" = sr."tenancyId"
47+
WHERE sr."tenancyId" = ${tenancyId}::UUID
48+
${suffixSql}
49+
`;
50+
}
51+
52+
export async function aggregateSessionReplayChunksByReplayIds(
53+
prisma: PrismaClientWithReplica<PrismaClient>,
54+
tenancyId: string,
55+
sessionReplayIds: string[],
56+
): Promise<Map<string, SessionReplayChunkAgg>> {
57+
if (sessionReplayIds.length === 0) {
58+
return new Map();
59+
}
60+
const chunkAggs = await prisma.sessionReplayChunk.groupBy({
61+
by: ["sessionReplayId"],
62+
where: { tenancyId, sessionReplayId: { in: sessionReplayIds } },
63+
_count: { _all: true },
64+
_sum: { eventCount: true },
65+
});
66+
const map = new Map<string, SessionReplayChunkAgg>();
67+
for (const a of chunkAggs) {
68+
map.set(a.sessionReplayId, {
69+
chunkCount: a._count._all,
70+
eventCount: a._sum.eventCount ?? 0,
71+
});
72+
}
73+
return map;
74+
}
75+
76+
export function sessionReplayAdminRowToApiItem(
77+
row: SessionReplayAdminListRow,
78+
agg: SessionReplayChunkAgg,
79+
) {
80+
return {
81+
id: row.id,
82+
project_user: {
83+
id: row.projectUserId,
84+
display_name: row.projectUserDisplayName ?? null,
85+
primary_email: row.primaryEmail ?? null,
86+
},
87+
started_at_millis: row.startedAt.getTime(),
88+
last_event_at_millis: row.lastEventAt.getTime(),
89+
chunk_count: agg.chunkCount,
90+
event_count: agg.eventCount,
91+
};
92+
}

apps/dashboard/instrumentation-client.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
1414
const postHogKey = getPublicEnvVar('NEXT_PUBLIC_POSTHOG_KEY') ?? "phc_vIUFi0HzHo7oV26OsaZbUASqxvs8qOmap1UBYAutU4k";
1515
if (postHogKey.length > 5) {
1616
posthog.init(postHogKey, {
17-
session_recording: {
18-
maskAllInputs: false,
19-
maskInputOptions: {
20-
password: true,
21-
},
22-
},
17+
// We use Sentry's Replay integration below for error debugging. Keep
18+
// PostHog session recording off to avoid loading its lazy recorder, which
19+
// is the source of Sentry issue STACK-SERVER-1NK:
20+
// "Called on script loaded before session recording is available".
21+
// PostHog documents `disable_session_recording: true` as the config-level
22+
// way to prevent automatic web session recording.
23+
// Source: https://posthog.com/docs/session-replay/how-to-control-which-sessions-you-record
24+
disable_session_recording: true,
2325
defaults: '2025-11-30',
2426
api_host: "/consume",
2527
ui_host: "https://eu.i.posthog.com",

apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/components.tsx

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
Typography,
2424
cn,
2525
} from "@/components/ui";
26-
import { CheckCircleIcon, WarningCircleIcon } from "@phosphor-icons/react";
26+
import { ArrowLeftIcon, CheckCircleIcon, WarningCircleIcon } from "@phosphor-icons/react";
2727
import { AdminOwnedProject } from "@stackframe/stack";
2828
import { ALL_APPS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
2929
import { previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/emails";
@@ -40,6 +40,7 @@ export type OnboardingPageProps = {
4040
disabled?: boolean,
4141
primaryAction: ReactNode,
4242
secondaryAction?: ReactNode,
43+
onBack?: () => void,
4344
wide?: boolean,
4445
actionsLayout?: "stacked" | "inline",
4546
children: ReactNode,
@@ -92,7 +93,18 @@ export function OnboardingPage(props: OnboardingPageProps) {
9293
</div>
9394

9495
<div className="onboarding-cascade fixed bottom-6 left-0 right-0 z-50 flex justify-center" style={{ "--cascade-i": 3 } as CSSProperties}>
95-
<div className="flex items-center gap-[5px]">
96+
<div className="relative flex items-center gap-[5px]">
97+
{props.onBack != null && (
98+
<button
99+
type="button"
100+
onClick={props.onBack}
101+
disabled={props.disabled}
102+
aria-label="Go back to previous step"
103+
className="absolute right-full mr-3 inline-flex h-5 w-5 items-center justify-center rounded-full text-foreground/40 transition-colors hover:text-foreground/80 disabled:cursor-not-allowed disabled:opacity-40"
104+
>
105+
<ArrowLeftIcon className="h-3.5 w-3.5" weight="bold" />
106+
</button>
107+
)}
96108
{props.steps.map((step, index) => {
97109
const isComplete = index < currentIndex;
98110
const isCurrent = index === currentIndex;
@@ -124,16 +136,6 @@ export function OnboardingPage(props: OnboardingPageProps) {
124136
);
125137
}
126138

127-
function appStageBadgeColor(stage: (typeof ALL_APPS)[AppId]["stage"]) {
128-
if (stage === "alpha") {
129-
return "orange";
130-
}
131-
if (stage === "beta") {
132-
return "blue";
133-
}
134-
return null;
135-
}
136-
137139
export type OnboardingAppCardProps = {
138140
appId: AppId,
139141
selected: boolean,
@@ -145,7 +147,6 @@ export type OnboardingAppCardProps = {
145147

146148
export function OnboardingAppCard(props: OnboardingAppCardProps) {
147149
const app = ALL_APPS[props.appId];
148-
const stageBadgeColor = appStageBadgeColor(app.stage);
149150

150151
return (
151152
<Tooltip delayDuration={0}>
@@ -190,13 +191,6 @@ export function OnboardingAppCard(props: OnboardingAppCardProps) {
190191
{props.required && (
191192
<DesignBadge label="Required" color="orange" size="sm" />
192193
)}
193-
{!props.required && stageBadgeColor != null && (
194-
<DesignBadge
195-
label={app.stage === "alpha" ? "Alpha" : "Beta"}
196-
color={stageBadgeColor}
197-
size="sm"
198-
/>
199-
)}
200194
</div>
201195
<Typography className="text-xs leading-relaxed text-muted-foreground">
202196
{app.subtitle}
@@ -544,7 +538,6 @@ export function ModeNotImplementedCard(props: { onBack: () => void }) {
544538
variant="warning"
545539
title="Not available yet"
546540
description="Linking an existing config into onboarding is not available yet."
547-
glassmorphic
548541
/>
549542
<div className="flex justify-center">
550543
<DesignButton variant="outline" className="rounded-full px-8" onClick={props.onBack}>

0 commit comments

Comments
 (0)