Skip to content

Commit 6fc1056

Browse files
committed
feat(sesh_rep): add limits to session replays
we block new creations of session replays when limit is hit. Session replays refresh monthly
1 parent 1154693 commit 6fc1056

6 files changed

Lines changed: 215 additions & 2 deletions

File tree

apps/backend/prisma/seed.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ export async function seed() {
133133
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.free.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const },
134134
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.free.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
135135
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.free.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const },
136+
[ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.free.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const },
136137
},
137138
},
138139
team: {
@@ -154,6 +155,7 @@ export async function seed() {
154155
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.team.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const },
155156
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.team.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
156157
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.team.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const },
158+
[ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.team.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const },
157159
},
158160
},
159161
growth: {
@@ -175,6 +177,7 @@ export async function seed() {
175177
[ITEM_IDS.emailsPerMonth]: { quantity: PLAN_LIMITS.growth.emailsPerMonth, repeat: [1, "month"] as any, expires: "when-repeated" as const },
176178
[ITEM_IDS.analyticsTimeoutSeconds]: { quantity: PLAN_LIMITS.growth.analyticsTimeoutSeconds, repeat: "never" as const, expires: "when-purchase-expires" as const },
177179
[ITEM_IDS.analyticsEvents]: { quantity: PLAN_LIMITS.growth.analyticsEvents, repeat: "never" as const, expires: "when-purchase-expires" as const },
180+
[ITEM_IDS.sessionReplays]: { quantity: PLAN_LIMITS.growth.sessionReplays, repeat: [1, "month"] as any, expires: "when-repeated" as const },
178181
},
179182
},
180183
"extra-seats": {
@@ -205,6 +208,7 @@ export async function seed() {
205208
[ITEM_IDS.emailsPerMonth]: { displayName: "Emails per Month", customerType: "team" as const },
206209
[ITEM_IDS.analyticsTimeoutSeconds]: { displayName: "Analytics Timeout (seconds)", customerType: "team" as const },
207210
[ITEM_IDS.analyticsEvents]: { displayName: "Analytics Events", customerType: "team" as const },
211+
[ITEM_IDS.sessionReplays]: { displayName: "Session Replays", customerType: "team" as const },
208212
},
209213
},
210214
apps: {

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import { getPrismaClientForTenancy } from "@/prisma-client";
22
import { uploadBytes } from "@/s3";
33
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
44
import { Prisma } from "@/generated/prisma/client";
5+
import { getBillingTeamId } from "@/lib/plan-entitlements";
56
import { findRecentSessionReplay } from "@/lib/session-replays";
7+
import { getStackServerApp } from "@/stack";
68
import { KnownErrors } from "@stackframe/stack-shared";
9+
import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans";
710
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
811
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
912
import { randomUUID } from "node:crypto";
@@ -106,6 +109,17 @@ export const POST = createSmartRouteHandler({
106109
const prisma = await getPrismaClientForTenancy(auth.tenancy);
107110
const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId });
108111

112+
const app = getStackServerApp();
113+
114+
const isNewSession = recentSession == null;
115+
const billingTeamId = getBillingTeamId(auth.tenancy.project);
116+
if (isNewSession && billingTeamId != null) {
117+
const replaysItem = await app.getItem({ itemId: ITEM_IDS.sessionReplays, teamId: billingTeamId });
118+
if (replaysItem.quantity <= 0) {
119+
throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.sessionReplays, billingTeamId, replaysItem.quantity);
120+
}
121+
}
122+
109123
const replayId = recentSession?.id ?? randomUUID();
110124
const s3Key = `session-replays/${projectId}/${branchId}/${replayId}/${batchId}.json.gz`;
111125

@@ -197,6 +211,11 @@ export const POST = createSmartRouteHandler({
197211
throw e;
198212
}
199213

214+
if (isNewSession && billingTeamId != null) {
215+
const replaysItem = await app.getItem({ itemId: ITEM_IDS.sessionReplays, teamId: billingTeamId });
216+
await replaysItem.decreaseQuantity(1);
217+
}
218+
200219
return {
201220
statusCode: 200,
202221
bodyType: "json",

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/replays/page-client.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { TeamSearchTable } from "@/components/data-table/team-search-table";
2626
import { AppEnabledGuard } from "../../app-enabled-guard";
2727
import { PageLayout } from "../../page-layout";
2828
import { useAdminApp } from "../../use-admin-app";
29-
import { AnalyticsEventLimitBanner } from "../shared";
29+
import { AnalyticsEventLimitBanner, SessionReplayLimitBanner } from "../shared";
3030
import {
3131
createInitialState,
3232
replayReducer,
@@ -1386,6 +1386,7 @@ export default function PageClient() {
13861386
<AppEnabledGuard appId="analytics">
13871387
<PageLayout title="Session Replays" fillWidth>
13881388
<AnalyticsEventLimitBanner />
1389+
<SessionReplayLimitBanner />
13891390
<PanelGroup direction="horizontal" className="!h-[calc(100vh-180px)] min-h-[520px] rounded-xl border border-border/40 overflow-hidden bg-background">
13901391
<Panel defaultSize={25} minSize={16}>
13911392
<div className="h-full flex flex-col">

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,58 @@ export function AnalyticsEventLimitBanner() {
349349
return <AnalyticsEventLimitBannerInner team={ownerTeam} />;
350350
}
351351

352+
/**
353+
* Shows a warning banner when session replay usage is at 80%+ or 100%.
354+
* Since the limit is the same across all plans, no upgrade button is shown.
355+
*/
356+
export function SessionReplayLimitBanner() {
357+
const adminApp = useAdminApp();
358+
const project = adminApp.useProject();
359+
const user = useUser({ or: "redirect", projectIdMustMatch: "internal" });
360+
const teams = user.useTeams();
361+
362+
const ownerTeam = useMemo(
363+
() => teams.find(t => t.id === project.ownerTeamId),
364+
[teams, project.ownerTeamId],
365+
);
366+
367+
if (ownerTeam == null) {
368+
return null;
369+
}
370+
371+
return <SessionReplayLimitBannerInner team={ownerTeam} />;
372+
}
373+
374+
function SessionReplayLimitBannerInner({ team }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type: string }> } }) {
375+
const replaysItem = team.useItem("session_replays");
376+
const products = team.useProducts();
377+
const planId = resolvePlanId(products);
378+
const totalAllocation = PLAN_LIMITS[planId].sessionReplays;
379+
const used = totalAllocation - replaysItem.quantity;
380+
const usagePercent = totalAllocation > 0 ? (used / totalAllocation) * 100 : 0;
381+
382+
if (usagePercent < 80) {
383+
return null;
384+
}
385+
386+
const isExhausted = replaysItem.quantity <= 0;
387+
388+
return (
389+
<Alert
390+
variant={isExhausted ? "destructive" : "default"}
391+
className={isExhausted ? undefined : "border-amber-500/50 text-amber-700 dark:text-amber-400 bg-amber-500/5 [&>svg]:text-amber-500"}
392+
>
393+
<WarningCircleIcon className="h-4 w-4" />
394+
<AlertDescription>
395+
{isExhausted
396+
? "You've reached your session replay limit for this month. New session replays are no longer being recorded."
397+
: "You're approaching your session replay limit for this month."
398+
}
399+
</AlertDescription>
400+
</Alert>
401+
);
402+
}
403+
352404
function AnalyticsEventLimitBannerInner({ team }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type: string }>, createCheckoutUrl: (options: { productId: string, returnUrl: string }) => Promise<string> } }) {
353405
const eventsItem = team.useItem("analytics_events");
354406
const products = team.useProducts();

apps/e2e/tests/backend/endpoints/api/v1/session-replays.test.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { randomUUID } from "node:crypto";
2+
import { PLAN_LIMITS } from "@stackframe/stack-shared/dist/plans";
23
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
34
import { it } from "../../../../helpers";
4-
import { Auth, Project, Team, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../backend-helpers";
5+
import { Auth, InternalProjectKeys, Project, Team, backendContext, bumpEmailAddress, niceBackendFetch } from "../../../backend-helpers";
56

67
async function uploadBatch(options: {
78
browserSessionId: string,
@@ -1382,3 +1383,134 @@ it("admin list session replays rejects invalid filter parameters", async ({ expe
13821383
}
13831384
`);
13841385
});
1386+
1387+
// ============================================================================
1388+
// Session replay limit enforcement tests
1389+
// ============================================================================
1390+
1391+
async function withInternalProject<T>(fn: () => Promise<T>): Promise<T> {
1392+
const savedKeys = backendContext.value.projectKeys;
1393+
const savedUserAuth = backendContext.value.userAuth;
1394+
backendContext.set({ projectKeys: InternalProjectKeys, userAuth: null });
1395+
try {
1396+
return await fn();
1397+
} finally {
1398+
backendContext.set({ projectKeys: savedKeys, userAuth: savedUserAuth });
1399+
}
1400+
}
1401+
1402+
async function getSessionReplayItemQuantity(ownerTeamId: string) {
1403+
return await withInternalProject(async () => {
1404+
const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/session_replays`, {
1405+
accessType: "server",
1406+
});
1407+
if (response.status !== 200) {
1408+
throw new Error(`Failed to get session_replays item: ${JSON.stringify(response.body)}`);
1409+
}
1410+
return response.body.quantity as number;
1411+
});
1412+
}
1413+
1414+
async function setSessionReplayItemQuantity(ownerTeamId: string, quantity: number) {
1415+
const currentQuantity = await getSessionReplayItemQuantity(ownerTeamId);
1416+
const delta = quantity - currentQuantity;
1417+
1418+
await withInternalProject(async () => {
1419+
const response = await niceBackendFetch(`/api/v1/payments/items/team/${ownerTeamId}/session_replays/update-quantity?allow_negative=true`, {
1420+
method: "POST",
1421+
accessType: "server",
1422+
body: { delta },
1423+
});
1424+
if (response.status !== 200) {
1425+
throw new Error(`Failed to set session_replays quantity: ${JSON.stringify(response.body)}`);
1426+
}
1427+
});
1428+
}
1429+
1430+
it("free plan starts with correct session replay allocation", async ({ expect }) => {
1431+
const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } });
1432+
const ownerTeamId = createProjectResponse.body.owner_team_id;
1433+
1434+
const quantity = await getSessionReplayItemQuantity(ownerTeamId);
1435+
expect(quantity).toBe(PLAN_LIMITS.free.sessionReplays);
1436+
});
1437+
1438+
it("rejects new session replay when quota is exhausted", async ({ expect }) => {
1439+
const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } });
1440+
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
1441+
const ownerTeamId = createProjectResponse.body.owner_team_id;
1442+
1443+
await Auth.Otp.signIn();
1444+
await setSessionReplayItemQuantity(ownerTeamId, 0);
1445+
1446+
const now = Date.now();
1447+
const res = await uploadBatch({
1448+
browserSessionId: randomUUID(),
1449+
batchId: randomUUID(),
1450+
startedAtMs: now,
1451+
sentAtMs: now + 500,
1452+
events: [{ type: 2, timestamp: now + 100 }],
1453+
});
1454+
1455+
expect(res.status).toBe(400);
1456+
expect(res.body.code).toBe("ITEM_QUANTITY_INSUFFICIENT_AMOUNT");
1457+
});
1458+
1459+
it("accepts new session replay and debits quota by 1", async ({ expect }) => {
1460+
const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } });
1461+
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
1462+
const ownerTeamId = createProjectResponse.body.owner_team_id;
1463+
1464+
await Auth.Otp.signIn();
1465+
1466+
const quantityBefore = await getSessionReplayItemQuantity(ownerTeamId);
1467+
1468+
const now = Date.now();
1469+
const res = await uploadBatch({
1470+
browserSessionId: randomUUID(),
1471+
batchId: randomUUID(),
1472+
startedAtMs: now,
1473+
sentAtMs: now + 500,
1474+
events: [{ type: 2, timestamp: now + 100 }],
1475+
});
1476+
1477+
expect(res.status).toBe(200);
1478+
expect(res.body.deduped).toBe(false);
1479+
1480+
const quantityAfter = await getSessionReplayItemQuantity(ownerTeamId);
1481+
expect(quantityAfter).toBe(quantityBefore - 1);
1482+
});
1483+
1484+
it("does not debit quota when appending chunks to an existing session replay", async ({ expect }) => {
1485+
const { createProjectResponse } = await Project.createAndSwitch({ config: { magic_link_enabled: true } });
1486+
await Project.updateConfig({ apps: { installed: { analytics: { enabled: true } } } });
1487+
const ownerTeamId = createProjectResponse.body.owner_team_id;
1488+
1489+
await Auth.Otp.signIn();
1490+
1491+
const now = Date.now();
1492+
const firstBatch = await uploadBatch({
1493+
browserSessionId: randomUUID(),
1494+
batchId: randomUUID(),
1495+
startedAtMs: now,
1496+
sentAtMs: now + 500,
1497+
events: [{ type: 2, timestamp: now + 100 }],
1498+
});
1499+
expect(firstBatch.status).toBe(200);
1500+
expect(firstBatch.body.deduped).toBe(false);
1501+
1502+
const quantityAfterFirst = await getSessionReplayItemQuantity(ownerTeamId);
1503+
1504+
const secondBatch = await uploadBatch({
1505+
browserSessionId: randomUUID(),
1506+
batchId: randomUUID(),
1507+
startedAtMs: now,
1508+
sentAtMs: now + 1000,
1509+
events: [{ type: 3, timestamp: now + 500 }],
1510+
});
1511+
expect(secondBatch.status).toBe(200);
1512+
expect(secondBatch.body.session_replay_id).toBe(firstBatch.body.session_replay_id);
1513+
1514+
const quantityAfterSecond = await getSessionReplayItemQuantity(ownerTeamId);
1515+
expect(quantityAfterSecond).toBe(quantityAfterFirst);
1516+
});

packages/stack-shared/src/plans.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const ITEM_IDS = {
1616
emailsPerMonth: "emails_per_month",
1717
analyticsTimeoutSeconds: "analytics_timeout_seconds",
1818
analyticsEvents: "analytics_events",
19+
sessionReplays: "session_replays",
1920
} as const;
2021

2122
export type ItemId = typeof ITEM_IDS[keyof typeof ITEM_IDS];
@@ -29,6 +30,7 @@ export type PlanProductOfferings = {
2930
emailsPerMonth: number,
3031
analyticsTimeoutSeconds: number,
3132
analyticsEvents: number,
33+
sessionReplays: number,
3234
};
3335

3436
/**
@@ -45,20 +47,23 @@ export const PLAN_LIMITS: {
4547
emailsPerMonth: 1_000,
4648
analyticsTimeoutSeconds: 10,
4749
analyticsEvents: 100_000,
50+
sessionReplays: 2_500,
4851
},
4952
team: {
5053
seats: 4,
5154
authUsers: 50_000,
5255
emailsPerMonth: 25_000,
5356
analyticsTimeoutSeconds: 60,
5457
analyticsEvents: 500_000,
58+
sessionReplays: 2_500,
5559
},
5660
growth: {
5761
seats: 4,
5862
authUsers: UNLIMITED,
5963
emailsPerMonth: 25_000,
6064
analyticsTimeoutSeconds: 300,
6165
analyticsEvents: 1_000_000,
66+
sessionReplays: 2_500,
6267
},
6368
};
6469

0 commit comments

Comments
 (0)