Skip to content

Commit f693f13

Browse files
committed
feat(events): add hard cap to event write
This applies to both client side and server side events. Two write points for events to clickhouse-batch route for client side events, and logEvent for server side. Max batch size for client side events is 500. We decrease item quantity after batch is uploaded for clickhouse. The alternative is to either do a check for eventsItem.quantity -batchSize <=0 before the upload and block upsert based on it (which would result in users not getting their full limit) or doing some weird partial batch upload. At most, with this approach, we would give users ~500 extra events. This is 0.5% extra on free plan, so we judge that it is ok. frontend-banners pop up on analytics when at 80%+ and when you hit your limit, with an option to upgrade your plan. We deliberated using tryDecreaseQuantity instead to both check and debit items, but there is potential for debit to happen even when clickhouse is down with that approach. This would be worse for users, so we accept the slight race condition. Manual testing: on local, changed events for free and team plan to smaller numbers. Checked analytics tables to verify no new events being added after limit reached.
1 parent e527e6c commit f693f13

7 files changed

Lines changed: 291 additions & 2 deletions

File tree

apps/backend/src/app/api/latest/analytics/events/batch/route.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { getClickhouseAdminClient } from "@/lib/clickhouse";
2+
import { getBillingTeamId } from "@/lib/plan-entitlements";
23
import { findRecentSessionReplay } from "@/lib/session-replays";
4+
import { getStackServerApp } from "@/stack";
35
import { getPrismaClientForTenancy } from "@/prisma-client";
46
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
57
import { KnownErrors } from "@stackframe/stack-shared";
8+
import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans";
69
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
710
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
811

@@ -61,6 +64,16 @@ export const POST = createSmartRouteHandler({
6164
const refreshTokenId = auth.refreshTokenId;
6265
const tenancyId = auth.tenancy.id;
6366

67+
const app = getStackServerApp();
68+
69+
const billingTeamId = getBillingTeamId(auth.tenancy.project);
70+
if (billingTeamId != null) {
71+
const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId });
72+
if (eventsItem.quantity <= 0) {
73+
throw new KnownErrors.ItemQuantityInsufficientAmount(ITEM_IDS.analyticsEvents, billingTeamId, eventsItem.quantity);
74+
}
75+
}
76+
6477
const prisma = await getPrismaClientForTenancy(auth.tenancy);
6578
const recentSession = await findRecentSessionReplay(prisma, { tenancyId, refreshTokenId });
6679

@@ -89,6 +102,11 @@ export const POST = createSmartRouteHandler({
89102
},
90103
});
91104

105+
if (billingTeamId != null) {
106+
const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId });
107+
await eventsItem.decreaseQuantity(body.events.length);
108+
}
109+
92110
return {
93111
statusCode: 200,
94112
bodyType: "json",

apps/backend/src/lib/events.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import withPostHog from "@/analytics";
22
import { globalPrismaClient } from "@/prisma-client";
3+
import { getBillingTeamId } from "@/lib/plan-entitlements";
4+
import { getStackServerApp } from "@/stack";
35
import { runAsynchronouslyAndWaitUntil } from "@/utils/vercel";
6+
import { ITEM_IDS } from "@stackframe/stack-shared/dist/plans";
47
import { urlSchema, yupBoolean, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
58
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
6-
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
9+
import { captureError, StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
710
import { HTTP_METHODS } from "@stackframe/stack-shared/dist/utils/http";
811
import { filterUndefined, typedKeys } from "@stackframe/stack-shared/dist/utils/objects";
912
import { UnionToIntersection } from "@stackframe/stack-shared/dist/utils/types";
@@ -264,6 +267,31 @@ export async function logEvent<T extends EventType[]>(
264267

265268
// rest is no more dynamic APIs so we can run it asynchronously
266269
runAsynchronouslyAndWaitUntil((async () => {
270+
// Resolve billing team for analytics event quota enforcement
271+
let billingTeamId: string | null = null;
272+
if (projectId) {
273+
const project = await globalPrismaClient.project.findUnique({
274+
where: { id: projectId },
275+
select: { id: true, ownerTeamId: true },
276+
});
277+
if (project != null) {
278+
billingTeamId = getBillingTeamId(project);
279+
}
280+
}
281+
282+
// Check analytics event limit before writing anything (Postgres + ClickHouse treated as atomic)
283+
if (billingTeamId != null) {
284+
const app = getStackServerApp();
285+
const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId });
286+
if (eventsItem.quantity <= 0) {
287+
captureError("logEvent", new StackAssertionError(
288+
`Analytics event limit exceeded, dropping event. Project: ${projectId}, owner team: ${billingTeamId}, remaining quantity: ${eventsItem.quantity}`,
289+
{ projectId, ownerTeamId: billingTeamId },
290+
));
291+
return;
292+
}
293+
}
294+
267295
// log event in DB
268296
await globalPrismaClient.event.create({
269297
data: {
@@ -377,6 +405,13 @@ export async function logEvent<T extends EventType[]>(
377405
});
378406
}
379407

408+
// Debit analytics event quota after successful writes
409+
if (billingTeamId != null) {
410+
const app = getStackServerApp();
411+
const eventsItem = await app.getItem({ itemId: ITEM_IDS.analyticsEvents, teamId: billingTeamId });
412+
await eventsItem.decreaseQuantity(1);
413+
}
414+
380415
// log event in PostHog
381416
if (getNodeEnvironment().includes("production") && !getEnvVariable("CI", "")) {
382417
await withPostHog(async posthog => {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { AppEnabledGuard } from "../../app-enabled-guard";
3535
import { PageLayout } from "../../page-layout";
3636
import { useAdminApp } from "../../use-admin-app";
3737
import {
38+
AnalyticsEventLimitBanner,
3839
ErrorDisplay,
3940
FolderWithId,
4041
RowData,
@@ -838,6 +839,7 @@ export default function PageClient() {
838839
return (
839840
<AppEnabledGuard appId="analytics">
840841
<PageLayout fillWidth noPadding>
842+
<AnalyticsEventLimitBanner />
841843
<QueriesContent />
842844
</PageLayout>
843845
</AppEnabledGuard>

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +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";
2930
import {
3031
createInitialState,
3132
replayReducer,
@@ -1384,6 +1385,7 @@ export default function PageClient() {
13841385
return (
13851386
<AppEnabledGuard appId="analytics">
13861387
<PageLayout title="Session Replays" fillWidth>
1388+
<AnalyticsEventLimitBanner />
13871389
<PanelGroup direction="horizontal" className="!h-[calc(100vh-180px)] min-h-[520px] rounded-xl border border-border/40 overflow-hidden bg-background">
13881390
<Panel defaultSize={25} minSize={16}>
13891391
<div className="h-full flex flex-col">

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@ import {
1414
ArrowClockwiseIcon,
1515
WarningCircleIcon
1616
} from "@phosphor-icons/react";
17+
import { Alert, AlertDescription, Button } from "@/components/ui";
18+
import { useUser } from "@stackframe/stack";
19+
import { PLAN_LIMITS, type PlanId } from "@stackframe/stack-shared/dist/plans";
1720
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
1821
import { useVirtualizer } from "@tanstack/react-virtual";
1922
import { useMemo, useRef } from "react";
23+
import { useAdminApp } from "../use-admin-app";
2024

2125
// ============================================================================
2226
// Types
@@ -316,3 +320,83 @@ export function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () =
316320
</div>
317321
);
318322
}
323+
324+
function resolvePlanId(products: Array<{ id: string | null, type: string }>): PlanId {
325+
if (products.some(p => p.id === "growth" && p.type === "subscription")) return "growth";
326+
if (products.some(p => p.id === "team" && p.type === "subscription")) return "team";
327+
return "free";
328+
}
329+
330+
/**
331+
* Shows a warning banner when analytics event usage is at 80%+ or 100%.
332+
* Fetches the billing team's analytics_events item and computes usage against the plan's total allocation.
333+
*/
334+
export function AnalyticsEventLimitBanner() {
335+
const adminApp = useAdminApp();
336+
const project = adminApp.useProject();
337+
const user = useUser({ or: "redirect", projectIdMustMatch: "internal" });
338+
const teams = user.useTeams();
339+
340+
const ownerTeam = useMemo(
341+
() => teams.find(t => t.id === project.ownerTeamId),
342+
[teams, project.ownerTeamId],
343+
);
344+
345+
if (ownerTeam == null) {
346+
return null;
347+
}
348+
349+
return <AnalyticsEventLimitBannerInner team={ownerTeam} />;
350+
}
351+
352+
function AnalyticsEventLimitBannerInner({ team }: { team: { useItem: (itemId: string) => { quantity: number }, useProducts: () => Array<{ id: string | null, type: string }>, createCheckoutUrl: (options: { productId: string, returnUrl: string }) => Promise<string> } }) {
353+
const eventsItem = team.useItem("analytics_events");
354+
const products = team.useProducts();
355+
const planId = resolvePlanId(products);
356+
const totalAllocation = PLAN_LIMITS[planId].analyticsEvents;
357+
const used = totalAllocation - eventsItem.quantity;
358+
const usagePercent = totalAllocation > 0 ? (used / totalAllocation) * 100 : 0;
359+
360+
if (usagePercent < 80) {
361+
return null;
362+
}
363+
364+
const isExhausted = eventsItem.quantity <= 0;
365+
const canUpgrade = planId !== "growth";
366+
367+
const handleUpgrade = async () => {
368+
const targetProduct = planId === "free" ? "team" : "growth";
369+
const checkoutUrl = await team.createCheckoutUrl({
370+
productId: targetProduct,
371+
returnUrl: window.location.href,
372+
});
373+
window.location.assign(checkoutUrl);
374+
};
375+
376+
return (
377+
<Alert
378+
variant={isExhausted ? "destructive" : "default"}
379+
className={isExhausted ? undefined : "border-amber-500/50 text-amber-700 dark:text-amber-400 bg-amber-500/5 [&>svg]:text-amber-500"}
380+
>
381+
<WarningCircleIcon className="h-4 w-4" />
382+
<AlertDescription className="flex items-center justify-between gap-3">
383+
<span>
384+
{isExhausted
385+
? "You've reached your analytics event limit. New events are no longer being tracked."
386+
: "You're approaching your analytics event limit."
387+
}
388+
{canUpgrade && !isExhausted && " Consider upgrading your plan."}
389+
</span>
390+
{canUpgrade && (
391+
<Button
392+
size="sm"
393+
variant={isExhausted ? "destructive" : "outline"}
394+
onClick={handleUpgrade}
395+
>
396+
Upgrade plan
397+
</Button>
398+
)}
399+
</AlertDescription>
400+
</Alert>
401+
);
402+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { AppEnabledGuard } from "../../app-enabled-guard";
3131
import { PageLayout } from "../../page-layout";
3232
import { useAdminApp } from "../../use-admin-app";
3333
import {
34+
AnalyticsEventLimitBanner,
3435
isDateValue,
3536
isJsonValue,
3637
JsonValue,
@@ -592,6 +593,7 @@ export default function PageClient() {
592593
return (
593594
<AppEnabledGuard appId="analytics">
594595
<PageLayout fillWidth noPadding>
596+
<AnalyticsEventLimitBanner />
595597
<div className="flex flex-1 min-h-0 overflow-hidden -mx-2">
596598
{/* Left sidebar - table list (doesn't scroll, border extends full height) */}
597599
<div className="w-48 flex-shrink-0 border-r border-border/50 flex flex-col pl-2">

0 commit comments

Comments
 (0)