Merge pull request #50 from yusufipk/feat/acquisition-analytics

feat(analytics): record where paying customers actually came from
This commit is contained in:
Yusuf İpek
2026-08-05 18:47:51 +03:00
committed by GitHub
49 changed files with 3797 additions and 26 deletions
+4
View File
@@ -34,6 +34,10 @@ OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES="33554432"
# Run once after creating the bucket: bun run r2:configure-cors
# Or set CORS manually in Cloudflare R2 -> bucket -> Settings -> CORS policy.
OPENFRAME_REQUIRE_INVITE_CODE="true"
# Acquisition attribution and funnel events, read back on /admin/growth. Off by
# default: the rows only pay for themselves if you are running a signup funnel.
# Everything is written to this instance's own database and sent nowhere.
OPENFRAME_ENABLE_ANALYTICS="false"
SELF_HOSTED_AUTO_CREATE_BUCKET="false"
# ============================================================================
+1
View File
@@ -180,6 +180,7 @@ Behavior when disabled:
- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides Bunny direct-upload entry points. URL-based providers such as YouTube remain available.
- `OPENFRAME_ENABLE_S3_VIDEO_UPLOADS=true` (with `R2_*` configured) enables presigned uploads to your own S3-compatible storage. Set `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` — only one direct-upload backend can be active. The bucket must allow CORS `PUT` from your app origin (for example `http://localhost:3000` in dev and your production URL). For Docker + MinIO, keep `R2_ENDPOINT=http://minio:9000` (app-internal) and set `R2_PRESIGN_ENDPOINT` to the browser-reachable MinIO origin (for example `http://localhost:9000` locally, or `https://minio.example.com` when MinIO is behind a reverse proxy). Use the origin only — no path suffix. The app's Content-Security-Policy is generated from runtime env at request time, so published Docker images pick up custom `R2_PRESIGN_ENDPOINT` values without rebuilding or editing `next.config.ts`.
- `OPENFRAME_REQUIRE_INVITE_CODE=false` allows open registration while keeping invitation-link registration intact.
- `OPENFRAME_ENABLE_ANALYTICS=true` records first-touch attribution and funnel events into your own database, readable on `/admin/growth`. Off by default, and nothing leaves the instance either way.
For self-hosted MinIO behind a reverse proxy, choose one of these browser-facing layouts:
+9
View File
@@ -1,3 +1,5 @@
import { after } from 'next/server';
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
import { isInviteCodeRequired } from '@/lib/feature-flags';
import { getInvitationPreviewByToken } from '@/lib/invitations';
import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit';
@@ -8,6 +10,13 @@ interface RegisterPageProps {
}
export default async function RegisterPage({ searchParams }: RegisterPageProps) {
// Reaching this page is the funnel step. Recording it here rather than from the
// browser also keeps it honest: a prefetch of this route is filtered out by
// readPageVisitor, so signup starts can never outnumber the landing views
// above them.
const visitor = await readPageVisitor();
after(() => recordVisitorEvent('SIGNUP_STARTED', visitor));
const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
const githubEnabled = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
+12 -1
View File
@@ -1,6 +1,8 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { after } from 'next/server';
import { ComparisonPage } from '@/components/marketing/comparison-page';
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
import { auth } from '@/lib/auth';
import { comparisonPages, getComparisonPage } from '@/lib/marketing/comparison-pages';
import { buildComparisonJsonLd, buildComparisonMetadata } from '@/lib/marketing/metadata';
@@ -38,6 +40,15 @@ export default async function MarketingSlugPage({ params }: MarketingSlugPagePro
}
const session = await auth();
const isLoggedIn = Boolean(session?.user);
// A comparison page is a landing page: for most of these visitors it is the
// first thing they see, so it belongs in the same visitor count as `/`.
if (!isLoggedIn) {
const visitor = await readPageVisitor();
after(() => recordVisitorEvent('LANDING_VIEW', visitor));
}
const structuredData = buildComparisonJsonLd({
title: page.title,
description: page.metaDescription,
@@ -57,7 +68,7 @@ export default async function MarketingSlugPage({ params }: MarketingSlugPagePro
}}
/>
))}
<ComparisonPage page={page} isLoggedIn={Boolean(session?.user)} />
<ComparisonPage page={page} isLoggedIn={isLoggedIn} />
</>
);
}
+365
View File
@@ -0,0 +1,365 @@
import { Metadata } from 'next';
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
AT_RISK_SILENT_DAYS,
conversionRates,
getScoreboard,
type FunnelRates,
} from '@/lib/analytics/scoreboard';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
import { AlertTriangle, CreditCard, TrendingUp, Users } from 'lucide-react';
export const metadata: Metadata = {
title: 'Growth | OpenFrame',
description: 'Acquisition funnel and retention scoreboard',
};
function formatMoney(cents: number | null, currency: string) {
if (cents === null) return '—';
const safeCurrency = /^[a-zA-Z]{3}$/.test(currency) ? currency.toUpperCase() : 'USD';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: safeCurrency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(cents / 100);
}
function formatWeek(date: Date) {
return date.toISOString().slice(0, 10);
}
function formatDate(date: Date | null) {
return date ? date.toISOString().slice(0, 10) : 'never';
}
/** A percentage with the count it was computed from, because n matters here. */
function Rate({ rate, of }: { rate: number | null; of: number }) {
if (rate === null) return <span className="text-muted-foreground"></span>;
return (
<span>
{Math.round(rate * 100)}%<span className="text-muted-foreground"> /{of}</span>
</span>
);
}
const WEEK_COLUMNS: Array<{ key: string; label: string }> = [
{ key: 'visitors', label: 'Visitors' },
{ key: 'signups', label: 'Signup' },
{ key: 'firstVideo', label: 'Video' },
{ key: 'shareLinks', label: 'Share link' },
{ key: 'externalFeedback', label: 'Ext. feedback' },
{ key: 'trials', label: 'Trial' },
{ key: 'newPaid', label: 'New paid' },
{ key: 'canceled', label: 'Canceled' },
{ key: 'activePaid', label: 'Active paid' },
];
export default async function AdminGrowthPage() {
const session = await auth();
if (!session?.user?.isAdmin) {
redirect('/');
}
if (!isProductAnalyticsEnabled()) {
return (
<div className="flex-1 space-y-4 px-4 md:px-8">
<h2 className="text-3xl font-bold tracking-tight">Growth</h2>
<Card>
<CardContent className="pt-6 text-sm text-muted-foreground">
Acquisition tracking is off on this deployment. Set{' '}
<code className="font-mono">OPENFRAME_ENABLE_ANALYTICS=true</code> to start recording
the funnel. Nothing is collected until you do, and nothing is ever sent anywhere but
this instance&apos;s own database.
</CardContent>
</Card>
</div>
);
}
const scoreboard = await getScoreboard();
const latest = scoreboard.weeks[scoreboard.weeks.length - 1];
const window = scoreboard.weeks.reduce(
(sum, week) => ({
visitors: sum.visitors + week.visitors,
signups: sum.signups + week.signups,
firstVideo: sum.firstVideo + week.firstVideo,
shareLinks: sum.shareLinks + week.shareLinks,
externalFeedback: sum.externalFeedback + week.externalFeedback,
trials: sum.trials + week.trials,
newPaid: sum.newPaid + week.newPaid,
}),
{
visitors: 0,
signups: 0,
firstVideo: 0,
shareLinks: 0,
externalFeedback: 0,
trials: 0,
newPaid: 0,
}
);
const overall: FunnelRates = conversionRates(window);
return (
<div className="flex-1 space-y-4 px-4 md:px-8">
<div className="flex items-center justify-between space-y-2">
<h2 className="text-3xl font-bold tracking-tight">Growth</h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active paid</CardTitle>
<CreditCard className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{scoreboard.currentActivePaid ?? '—'}</div>
<p className="text-xs text-muted-foreground">from Stripe, right now</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">MRR</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatMoney(scoreboard.currentMrrCents, scoreboard.currency)}
</div>
<p className="text-xs text-muted-foreground">from Stripe, right now</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Visitors this week</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{latest?.visitors ?? 0}</div>
<p className="text-xs text-muted-foreground">
{latest ? `week of ${formatWeek(latest.weekStart)}` : 'no data yet'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">At risk</CardTitle>
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{scoreboard.atRisk.length}</div>
<p className="text-xs text-muted-foreground">
paid, silent for {AT_RISK_SILENT_DAYS}+ days
</p>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Weekly funnel</CardTitle>
<p className="text-sm text-muted-foreground">
Weeks start Monday, UTC. Active paid is the running net of subscriptions started minus
canceled, so it can drift from the Stripe figure above; the difference is the drift.
</p>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">Week</th>
{WEEK_COLUMNS.map((column) => (
<th key={column.key} className="py-2 pr-4 text-right font-medium">
{column.label}
</th>
))}
<th className="py-2 pr-4 text-right font-medium">MRR</th>
</tr>
</thead>
<tbody>
{scoreboard.weeks.map((week) => (
<tr key={week.weekStart.toISOString()} className="border-b last:border-0">
<td className="py-2 pr-4 font-mono text-xs">{formatWeek(week.weekStart)}</td>
{WEEK_COLUMNS.map((column) => (
<td key={column.key} className="py-2 pr-4 text-right tabular-nums">
{week[column.key as keyof typeof week] as number}
</td>
))}
<td className="py-2 pr-4 text-right tabular-nums">
{formatMoney(week.mrrCents, scoreboard.currency)}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Where it narrows</CardTitle>
<p className="text-sm text-muted-foreground">
Every step over the whole {scoreboard.weeks.length}-week window, with the denominator
beside it. The lowest rate is the step to work on.
</p>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-3 lg:grid-cols-5 text-sm">
<div>
<div className="text-muted-foreground">Visitor to signup</div>
<div className="text-lg font-semibold">
<Rate rate={overall.visitorToSignup} of={window.visitors} />
</div>
</div>
<div>
<div className="text-muted-foreground">Signup to first video</div>
<div className="text-lg font-semibold">
<Rate rate={overall.signupToFirstVideo} of={window.signups} />
</div>
</div>
<div>
<div className="text-muted-foreground">Video to share link</div>
<div className="text-lg font-semibold">
<Rate rate={overall.firstVideoToShare} of={window.firstVideo} />
</div>
</div>
<div>
<div className="text-muted-foreground">Share to outside feedback</div>
<div className="text-lg font-semibold">
<Rate rate={overall.shareToFeedback} of={window.shareLinks} />
</div>
</div>
<div>
<div className="text-muted-foreground">Trial to paid</div>
<div className="text-lg font-semibold">
<Rate rate={overall.trialToPaid} of={window.trials} />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">By source</CardTitle>
<p className="text-sm text-muted-foreground">
Rolling {scoreboard.channelWindowDays} days rather than one week: a weekly per-source
cell holds single digits at this volume, and a percentage computed from three visits
reads exactly as confidently as one computed from three hundred.
</p>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">Source</th>
<th className="py-2 pr-4 text-right font-medium">Visitors</th>
<th className="py-2 pr-4 text-right font-medium">Signup</th>
<th className="py-2 pr-4 text-right font-medium">Trial</th>
<th className="py-2 pr-4 text-right font-medium">Paid</th>
<th className="py-2 pr-4 text-right font-medium">Visitor to signup</th>
</tr>
</thead>
<tbody>
{scoreboard.channels.length === 0 && (
<tr>
<td colSpan={6} className="py-4 text-muted-foreground">
Nothing recorded in this window yet.
</td>
</tr>
)}
{scoreboard.channels.map((row) => (
<tr key={row.channel} className="border-b last:border-0">
<td className="py-2 pr-4">{row.channel.toLowerCase()}</td>
<td className="py-2 pr-4 text-right tabular-nums">{row.visitors}</td>
<td className="py-2 pr-4 text-right tabular-nums">{row.signups}</td>
<td className="py-2 pr-4 text-right tabular-nums">{row.trials}</td>
<td className="py-2 pr-4 text-right tabular-nums">{row.paid}</td>
<td className="py-2 pr-4 text-right tabular-nums">
<Rate
rate={row.visitors > 0 ? row.signups / row.visitors : null}
of={row.visitors}
/>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Paid accounts</CardTitle>
<p className="text-sm text-muted-foreground">
Value events are videos, share links, outside feedback, approvals and projects. Rows
marked at risk have produced none for {AT_RISK_SILENT_DAYS} days.
{scoreboard.paidAccountsTruncated && (
<>
{' '}
Quietest {scoreboard.paidAccountLimit} only; there are more paid accounts than this
table shows.
</>
)}
</p>
</CardHeader>
<CardContent className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 pr-4 font-medium">Account</th>
<th className="py-2 pr-4 font-medium">Status</th>
<th className="py-2 pr-4 font-medium">Source</th>
<th className="py-2 pr-4 text-right font-medium">7d</th>
<th className="py-2 pr-4 text-right font-medium">30d</th>
<th className="py-2 pr-4 font-medium">Last activity</th>
</tr>
</thead>
<tbody>
{scoreboard.paidAccounts.length === 0 && (
<tr>
<td colSpan={6} className="py-4 text-muted-foreground">
No active or trialing accounts.
</td>
</tr>
)}
{scoreboard.paidAccounts.map((account) => {
const atRisk = scoreboard.atRisk.some((row) => row.userId === account.userId);
return (
<tr key={account.userId} className="border-b last:border-0">
<td className="py-2 pr-4">
{account.name || account.email || account.userId}
{atRisk && (
<span className="ml-2 rounded bg-destructive/10 px-1.5 py-0.5 text-xs text-destructive">
at risk
</span>
)}
</td>
<td className="py-2 pr-4 text-muted-foreground">
{account.status.toLowerCase()}
</td>
<td className="py-2 pr-4 text-muted-foreground">
{account.channel?.toLowerCase() ?? '—'}
{account.selfReported && account.selfReported !== account.channel && (
<span className="text-xs">
{' '}
(said {account.selfReported.toLowerCase()})
</span>
)}
</td>
<td className="py-2 pr-4 text-right tabular-nums">{account.valueEvents7}</td>
<td className="py-2 pr-4 text-right tabular-nums">{account.valueEvents30}</td>
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
{formatDate(account.lastValueEventAt)}
</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
</div>
);
}
+15 -1
View File
@@ -2,7 +2,7 @@ import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
import { Header } from '@/components/layout';
import Link from 'next/link';
import { LayoutDashboard, MessageSquareQuote, Users } from 'lucide-react';
import { LayoutDashboard, MessageSquareQuote, TrendingUp, Users } from 'lucide-react';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
@@ -39,6 +39,13 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<MessageSquareQuote className="h-4 w-4" />
Feedback
</Link>
<Link
href="/admin/growth"
className="flex items-center gap-2 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50"
>
<TrendingUp className="h-4 w-4" />
Growth
</Link>
</nav>
</div>
{/* Desktop Nav */}
@@ -66,6 +73,13 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<MessageSquareQuote className="h-4 w-4" />
Feedback
</Link>
<Link
href="/admin/growth"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-muted/50 transition-colors"
>
<TrendingUp className="h-4 w-4" />
Growth
</Link>
</nav>
</div>
</aside>
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { conversionRates, getScoreboard } from '@/lib/analytics/scoreboard';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
import { logError } from '@/lib/logger';
// The same numbers /admin/growth renders, as JSON, so the Monday digest can pull
// the scoreboard instead of somebody retyping it into a table.
export async function GET(request: NextRequest) {
try {
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
if (!session.user.isAdmin) {
return apiErrors.forbidden('Admin access required');
}
if (!isProductAnalyticsEnabled()) {
return apiErrors.badRequest('Analytics are disabled by this host');
}
const weeksParam = Number(request.nextUrl.searchParams.get('weeks'));
const scoreboard = await getScoreboard({
weeks: Number.isSafeInteger(weeksParam) && weeksParam > 0 ? weeksParam : undefined,
});
const response = successResponse({
...scoreboard,
weeks: scoreboard.weeks.map((week) => ({ ...week, rates: conversionRates(week) })),
});
return withCacheControl(response, 'private, no-store');
} catch (error) {
logError('Error building the growth scoreboard:', error);
return apiErrors.internalError('Failed to build the scoreboard');
}
}
@@ -6,6 +6,7 @@ import { notifyUsers } from '@/lib/notifications';
import { rateLimit } from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
type RouteParams = { params: Promise<{ requestId: string }> };
@@ -202,6 +203,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
});
if (updated.status === 'APPROVED') {
await recordEvent({
name: 'APPROVAL_COMPLETED',
dedupeKey: eventKey('APPROVAL_COMPLETED', requestId),
userId: approvalRequest.version.video.project.ownerId,
});
notifyUsers([updated.requestedById], {
type: 'approval_completed',
projectName: updated.version.video.project.name,
+10
View File
@@ -17,6 +17,8 @@ import {
sendVerificationEmail,
} from '@/lib/email-verification';
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
import { recordSignupCompleted } from '@/lib/analytics/signup';
import { readRequestVisitor } from '@/lib/analytics/visitor';
export async function POST(request: NextRequest) {
try {
@@ -132,6 +134,14 @@ export async function POST(request: NextRequest) {
}
}
// Ties the account to the first touch stored in this browser's cookie and
// claims the visitor events that led here. Recorded after the invitation has
// been accepted, so an account that gets rolled back never leaves a signup.
await recordSignupCompleted({
userId: user.id,
visitor: await readRequestVisitor(request),
});
// Send verification email if SMTP is configured
if (emailVerificationRequired) {
const verificationToken = await createVerificationToken(normalizedEmail);
+10
View File
@@ -11,6 +11,7 @@ import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
function getAppOrigin(request: NextRequest) {
if (isTrustedSameOriginRequest(request)) {
@@ -84,6 +85,15 @@ export async function POST(request: NextRequest) {
throw new Error('Stripe did not return a checkout URL');
}
// Keyed on the Stripe session, so an abandoned checkout followed by a second
// attempt counts twice. That is the intent: the gap between checkouts started
// and subscriptions started is the number worth watching.
await recordEvent({
name: 'CHECKOUT_STARTED',
dedupeKey: eventKey('CHECKOUT_STARTED', checkoutSession.id),
userId: session.user.id,
});
const response = successResponse({ url: checkoutSession.url });
return withCacheControl(response, 'private, no-store');
} catch (error) {
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest } from 'next/server';
import { rateLimit } from '@/lib/rate-limit';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { readRequestVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
// The one funnel event that cannot be observed from the server: a click on a
// call to action, which never reaches us as a request of its own.
//
// Everything else in the funnel is recorded where it actually happens, so this
// endpoint accepts exactly one event name. An anonymous caller must not be able
// to post `SUBSCRIPTION_STARTED` into the scoreboard, and the cheapest way to
// guarantee that is to make the allowed set a single literal.
const ALLOWED_EVENTS = new Set(['cta_clicked']);
export async function POST(request: NextRequest) {
// Answers 204 whatever happens. This endpoint reports nothing back to the page
// that called it, so there is no reason to tell a caller which of their
// attempts landed.
const noContent = new Response(null, {
status: 204,
headers: { 'Cache-Control': 'private, no-store' },
});
// Both cheap and both free of side effects, so they come before the limiter.
// Checking the flag here rather than only inside the recorder keeps a host who
// never turned analytics on from paying a rate-limit write for every anonymous
// POST to an endpoint they are not using.
if (!isProductAnalyticsEnabled()) return noContent;
if (!isTrustedSameOriginRequest(request)) return noContent;
// 204 rather than the limiter's 429: a beacon has nobody to tell, and a
// flooder should not be handed a signal for when the window resets.
const limited = await rateLimit(request, 'analytics-beacon');
if (limited) return noContent;
const body = await request.json().catch(() => null);
const name = typeof body?.name === 'string' ? body.name : '';
if (!ALLOWED_EVENTS.has(name)) return noContent;
await recordVisitorEvent('CTA_CLICKED', await readRequestVisitor(request));
return noContent;
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { RATE_LIMIT_CONFIGS, checkRateLimit, rateLimitHeaders } from '@/lib/rate-limit';
import { setSelfReportedSource } from '@/lib/analytics/record';
import { isAcquisitionChannel } from '@/lib/analytics/cookies';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
// "How did you hear about us?", answered on the first onboarding screen.
//
// It is stored beside the cookie-derived channel rather than instead of it. The
// cookie is precise but loses cross-device visits and cleared browsers; the
// answer survives both, and it is the only thing that can name a channel no UTM
// tag ever carries, like being told about it by a friend.
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return apiErrors.unauthorized();
}
// Keyed by account, like /api/onboarding/complete beside it. An IP key would
// be the wrong bucket twice over: without TRUSTED_PROXY_MODE every caller
// resolves to 127.0.0.1, so five answers an hour would be five for the whole
// deployment, and with it a shared office address would lock out everyone
// after one colleague answered.
const config = RATE_LIMIT_CONFIGS['onboarding-source'];
const limit = await checkRateLimit(session.user.id, 'onboarding-source', config);
if (!limit.allowed) {
return new Response(JSON.stringify({ error: 'Too many requests. Please try again later.' }), {
status: 429,
headers: {
'Content-Type': 'application/json',
...rateLimitHeaders(limit, config.maxRequests),
},
});
}
if (!isProductAnalyticsEnabled()) {
return apiErrors.badRequest('Analytics are disabled by this host');
}
const body = await request.json().catch(() => null);
const source = body?.source;
if (!isAcquisitionChannel(source)) {
return apiErrors.badRequest('Unknown source');
}
const note = typeof body?.note === 'string' ? body.note : null;
await setSelfReportedSource({ userId: session.user.id, selfReported: source, note });
const response = successResponse({ recorded: true });
return withCacheControl(response, 'private, no-store');
}
@@ -8,6 +8,7 @@ import { db } from '@/lib/db';
import { rateLimit } from '@/lib/rate-limit';
import { MAX_SHARE_PASSWORD_LENGTH } from '@/lib/share-links';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -141,7 +142,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
const { projectId, videoId } = await params;
const { error } = await requireShareManagementAccess(projectId, videoId, session.user.id);
const { error, video } = await requireShareManagementAccess(
projectId,
videoId,
session.user.id
);
if (error) return error;
const body = await request.json().catch(() => ({}));
@@ -244,6 +249,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.internalError('Failed to create video share link');
}
// Keyed on the link id, so re-issuing the token for a link that already
// exists updates the row and records nothing: the share was created once.
await recordEvent({
name: 'SHARE_LINK_CREATED',
dedupeKey: eventKey('SHARE_LINK_CREATED', link.id),
userId: video?.project.ownerId ?? null,
});
const response = successResponse(serializeShareLink(request, videoId, link));
return withCacheControl(response, 'private, no-store');
@@ -8,6 +8,7 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
type RouteParams = { params: Promise<{ projectId: string }> };
@@ -268,6 +269,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}).catch((err) => logError('Notification failed:', err));
}
await recordEvent({
name: 'VIDEO_ADDED',
dedupeKey: eventKey('VIDEO_ADDED', video.id),
userId: project.ownerId,
});
const response = successResponse(video, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
+10
View File
@@ -7,6 +7,7 @@ import { buildBillingAccessWhereInput } from '@/lib/billing';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
// GET /api/projects - List all projects for the authenticated user
export async function GET(request: NextRequest) {
@@ -187,6 +188,15 @@ export async function POST(request: NextRequest) {
return createdProject;
});
// Attributed to the workspace owner rather than the caller: the funnel asks
// which account is progressing, and a team member creating a project moves
// the owner's account, not their own.
await recordEvent({
name: 'PROJECT_CREATED',
dedupeKey: eventKey('PROJECT_CREATED', project.id),
userId: workspace.ownerId,
});
const response = successResponse(project, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
@@ -13,6 +13,7 @@ import {
getGuestIdentityFromRequest,
setGuestIdentityCookie,
} from '@/lib/guest-identity';
import { eventKey, recordEvent } from '@/lib/analytics/record';
import {
extractImageFileNameFromProxyUrl,
extractAudioFileNameFromProxyUrl,
@@ -547,6 +548,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
}
// Feedback arriving from outside the team is the moment this product
// becomes worth paying for, so it is the activation step of the funnel.
// Keyed on the account, not the comment: what matters is the first time an
// account ever received one.
if (isGuest) {
await recordEvent({
name: 'FIRST_GUEST_COMMENT',
dedupeKey: eventKey('FIRST_GUEST_COMMENT', project.workspace.ownerId),
userId: project.workspace.ownerId,
});
}
const viewerUserId = session?.user?.id ?? null;
const viewerGuestIdentityId = viewerUserId
? null
+7
View File
@@ -5,6 +5,7 @@ import { rateLimit } from '@/lib/rate-limit';
import { buildBillingAccessWhereInput, getWorkspaceCreationEligibility } from '@/lib/billing';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
// GET /api/workspaces - List all workspaces for the authenticated user
export async function GET(request: NextRequest) {
@@ -136,6 +137,12 @@ export async function POST(request: NextRequest) {
},
});
await recordEvent({
name: 'WORKSPACE_CREATED',
dedupeKey: eventKey('WORKSPACE_CREATED', workspace.id),
userId: workspace.ownerId,
});
const response = successResponse(workspace, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
+74 -3
View File
@@ -107,7 +107,45 @@ function ToggleButton({
// ─── Step 1: Welcome ───────────────────────────────────────────────────────────
function StepWelcome({ userName, onNext }: { userName: string; onNext: () => void }) {
// Asked here rather than on the registration form. The whole point of measuring
// this funnel is the signup conversion rate, and a question added to the form
// would move the number being measured.
const SOURCE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: 'GITHUB', label: 'GitHub' },
{ value: 'YOUTUBE', label: 'YouTube' },
{ value: 'GOOGLE', label: 'A search engine' },
{ value: 'REVIEW_LINK', label: 'A review or comparison site' },
{ value: 'REFERRAL', label: 'Someone recommended it' },
{ value: 'COMMUNITY', label: 'Reddit, X, Discord or a forum' },
{ value: 'OUTBOUND', label: 'An email from us' },
{ value: 'OTHER', label: 'Somewhere else' },
];
function StepWelcome({
userName,
askSource,
onNext,
}: {
userName: string;
askSource: boolean;
onNext: () => void;
}) {
const [source, setSource] = useState<string>('');
const [note, setNote] = useState('');
const handleNext = () => {
// Never blocks the wizard. An unanswered or failed question costs one row in
// a cross-check column; a broken Get Started button costs the account.
if (askSource && source) {
void fetch('/api/onboarding/source', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source, note: source === 'OTHER' ? note : undefined }),
}).catch(() => undefined);
}
onNext();
};
return (
<div className="text-center space-y-8">
<div className="mx-auto w-24 h-24 rounded-full bg-primary/10 flex items-center justify-center">
@@ -122,7 +160,36 @@ function StepWelcome({ userName, onNext }: { userName: string; onNext: () => voi
manage versions, and streamline approvals all in one place.
</p>
</div>
<Button onClick={onNext} size="lg" className="w-full sm:w-auto px-10 h-12 text-base">
{askSource && (
<div className="mx-auto max-w-sm space-y-3 text-left">
<Label htmlFor="acquisition-source" className="text-sm text-muted-foreground">
How did you hear about us? (optional)
</Label>
<Select value={source} onValueChange={setSource}>
<SelectTrigger id="acquisition-source" className="w-full">
<SelectValue placeholder="Pick one" />
</SelectTrigger>
<SelectContent>
{SOURCE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{source === 'OTHER' && (
<Input
value={note}
onChange={(event) => setNote(event.target.value)}
maxLength={200}
placeholder="Where, roughly?"
/>
)}
</div>
)}
<Button onClick={handleNext} size="lg" className="w-full sm:w-auto px-10 h-12 text-base">
Get Started
<ChevronRight className="h-5 w-5 ml-1" />
</Button>
@@ -691,10 +758,12 @@ export function OnboardingWizard({
userName,
canCreateWorkspace,
availableWorkspaces,
askAcquisitionSource,
}: {
userName: string;
canCreateWorkspace: boolean;
availableWorkspaces: Array<{ id: string; name: string; isOwner: boolean }>;
askAcquisitionSource: boolean;
}) {
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
@@ -761,7 +830,9 @@ export function OnboardingWizard({
{/* Step content */}
<Card className="border-border/50 shadow-lg">
<CardContent className="pt-10 pb-10 px-10">
{currentStep === 1 && <StepWelcome userName={userName} onNext={goNext} />}
{currentStep === 1 && (
<StepWelcome userName={userName} askSource={askAcquisitionSource} onNext={goNext} />
)}
{currentStep === 2 && (
<StepWorkspace
canCreateWorkspace={canCreateWorkspace}
+2
View File
@@ -1,6 +1,7 @@
import { auth } from '@/lib/auth';
import { buildBillingAccessWhereInput, getBillingOverview } from '@/lib/billing';
import { db } from '@/lib/db';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
import { redirect } from 'next/navigation';
import { OnboardingWizard } from './onboarding-wizard';
@@ -43,6 +44,7 @@ export default async function OnboardingPage() {
<OnboardingWizard
userName={userName}
canCreateWorkspace={billing.workspaceCreation.canCreateWorkspace}
askAcquisitionSource={isProductAnalyticsEnabled()}
availableWorkspaces={creatableWorkspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
+11 -1
View File
@@ -1,8 +1,18 @@
import { after } from 'next/server';
import { LandingPage } from '@/components/LandingPage';
import { auth } from '@/lib/auth';
import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
export default async function HomePage() {
const session = await auth();
const isLoggedIn = Boolean(session?.user);
return <LandingPage isLoggedIn={Boolean(session?.user)} />;
// Signed-in users land here too, and counting them would put existing
// customers at the top of the acquisition funnel.
if (!isLoggedIn) {
const visitor = await readPageVisitor();
after(() => recordVisitorEvent('LANDING_VIEW', visitor));
}
return <LandingPage isLoggedIn={isLoggedIn} />;
}
+7 -6
View File
@@ -2,6 +2,7 @@
import Image from 'next/image';
import Link from 'next/link';
import { CtaLink } from '@/components/marketing/cta-link';
import { MarketingCompareLinks } from '@/components/marketing/marketing-compare-links';
import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
@@ -258,13 +259,13 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
data-hero-copy
className="mx-auto flex max-w-md flex-col items-center justify-center gap-3"
>
<Link
<CtaLink
href={hostedCtaHref}
className="group relative isolate inline-flex h-12 min-w-max items-center justify-center overflow-hidden border border-primary bg-primary px-10 text-sm font-medium whitespace-nowrap text-primary-foreground transition-transform duration-300 hover:scale-[1.02]"
>
Start free trial
<MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link>
</CtaLink>
<p className="text-xs text-muted-foreground">
7-day free trial · Flat $10/mo no per-seat fees · No client accounts
@@ -717,12 +718,12 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
Need more storage? Add 100 GB for $5/mo.
</p>
<Link
<CtaLink
href={hostedCtaHref}
className="mt-auto group relative isolate inline-flex h-12 w-full items-center justify-center overflow-hidden bg-[#06b6d4] font-medium text-black transition-colors hover:bg-[#06b6d4]/90 text-sm"
>
Start free trial
</Link>
</CtaLink>
</div>
{/* Card 2: Fair Source (Self-hosted) */}
@@ -901,12 +902,12 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
Your first review link takes minutes.
</p>
</div>
<Link
<CtaLink
href={hostedCtaHref}
className="group relative isolate inline-flex h-12 min-w-max items-center justify-center overflow-hidden border border-primary bg-primary px-10 text-sm font-medium whitespace-nowrap text-primary-foreground transition-transform duration-300 hover:scale-[1.02] md:min-w-[240px]"
>
Start free trial
</Link>
</CtaLink>
</div>
</section>
</main>
+5 -4
View File
@@ -1,5 +1,6 @@
import Link from 'next/link';
import { ArrowRight, Github, MoveRight } from 'lucide-react';
import { CtaLink } from '@/components/marketing/cta-link';
import { FeatureComparisonTable } from '@/components/marketing/feature-comparison-table';
import { MarketingFooter } from '@/components/marketing/marketing-footer';
import { MarketingHeader } from '@/components/marketing/marketing-header';
@@ -41,13 +42,13 @@ export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
$10/month hosted plan covers your whole team and every client reviewer link.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link
<CtaLink
href={hostedCtaHref}
className="group relative isolate inline-flex h-12 items-center justify-center overflow-hidden border border-primary bg-primary px-8 text-sm font-medium text-primary-foreground transition-transform duration-300 hover:scale-[1.02]"
>
Start free trial
<MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link>
</CtaLink>
<a
href={seoConfig.githubUrl}
target="_blank"
@@ -199,12 +200,12 @@ export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Link
<CtaLink
href={hostedCtaHref}
className="inline-flex h-12 items-center justify-center border border-primary bg-primary px-8 text-sm font-medium text-primary-foreground"
>
Start free trial
</Link>
</CtaLink>
<a
href={seoConfig.githubUrl}
target="_blank"
+30
View File
@@ -0,0 +1,30 @@
'use client';
import Link from 'next/link';
import { trackCtaClick } from '@/lib/client/track';
interface CtaLinkProps {
href: string;
className?: string;
children: React.ReactNode;
}
/**
* A marketing call to action that reports the click.
*
* Only the signup CTA counts. The same button reads "Start free trial" and
* points at `/dashboard` once you are signed in, and an existing customer
* clicking through to their own dashboard is not a step in the acquisition
* funnel.
*/
export function CtaLink({ href, className, children }: CtaLinkProps) {
return (
<Link
href={href}
className={className}
onClick={href === '/register' ? trackCtaClick : undefined}
>
{children}
</Link>
);
}
+88
View File
@@ -0,0 +1,88 @@
// Turning Stripe state into funnel events.
//
// These four events are derived from a before/after comparison inside the sync
// that already re-reads every subscription a customer has, rather than from the
// webhook event types. That is deliberate: webhooks arrive out of order and get
// replayed, and `customer.subscription.updated` fires for changes that mean
// nothing here. Comparing the row we are about to overwrite with the row we are
// writing is order-independent, and the dedupe keys make a replay a no-op.
import type { BillingSubscriptionStatus } from '@prisma/client';
import { eventKey, recordEvent } from '@/lib/analytics/record';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
export interface SubscriptionStateBefore {
status: BillingSubscriptionStatus;
cancelAtPeriodEnd: boolean;
/** Whether this account had already consumed a trial before this sync. */
hadTrial: boolean;
}
export interface SubscriptionStateAfter {
status: BillingSubscriptionStatus;
cancelAtPeriodEnd: boolean;
trialEndsAt: Date | null;
currentPeriodEnd: Date | null;
}
/**
* A cancellation and the reactivation that may follow it both belong to a
* billing cycle. Keying them on the period end lets a customer cancel, come
* back, and cancel again in a later cycle without the second one being
* swallowed as a duplicate, while the two Stripe writes that describe a single
* cancellation (the `cancel_at_period_end` flag now, the `canceled` status
* later) collapse into one event.
*/
function cycleMarker(currentPeriodEnd: Date | null): string {
return String(currentPeriodEnd ? currentPeriodEnd.getTime() : 0);
}
export async function recordSubscriptionTransition(params: {
userId: string;
subscriptionId: string;
before: SubscriptionStateBefore;
after: SubscriptionStateAfter;
}): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
const { userId, subscriptionId, before, after } = params;
const cycle = cycleMarker(after.currentPeriodEnd);
// Once per account for its lifetime. A second trial is not a second start of
// the funnel, and Stripe will not grant one anyway.
if (after.trialEndsAt && !before.hadTrial) {
await recordEvent({
name: 'TRIAL_STARTED',
dedupeKey: eventKey('TRIAL_STARTED', userId),
userId,
});
}
// The paying moment. With a trial the status goes trialing -> active, so this
// fires on conversion rather than on signup for the trial.
if (after.status === 'ACTIVE' && before.status !== 'ACTIVE') {
await recordEvent({
name: 'SUBSCRIPTION_STARTED',
dedupeKey: eventKey('SUBSCRIPTION_STARTED', subscriptionId),
userId,
});
}
const startedCanceling = after.cancelAtPeriodEnd && !before.cancelAtPeriodEnd;
const becameCanceled = after.status === 'CANCELED' && before.status !== 'CANCELED';
if (startedCanceling || becameCanceled) {
await recordEvent({
name: 'SUBSCRIPTION_CANCELED',
dedupeKey: `SUBSCRIPTION_CANCELED:${subscriptionId}:${cycle}`,
userId,
});
}
if (!after.cancelAtPeriodEnd && before.cancelAtPeriodEnd && after.status !== 'CANCELED') {
await recordEvent({
name: 'SUBSCRIPTION_REACTIVATED',
dedupeKey: `SUBSCRIPTION_REACTIVATED:${subscriptionId}:${cycle}`,
userId,
});
}
}
+42
View File
@@ -0,0 +1,42 @@
// Traffic that is not a person.
//
// This matters more than it looks. Visitors are the denominator of every
// conversion rate in the scoreboard, so counting a crawler as a visit does not
// add noise evenly: it quietly makes every channel look worse, and the channels
// that attract the most crawling (an indexed landing page, a GitHub README link)
// look worst of all.
const BOT_PATTERN =
/bot\b|bots\b|crawler|spider|crawl|slurp|facebookexternalhit|embedly|quora link preview|whatsapp|telegram|discordbot|slackbot|preview|monitor|uptime|pingdom|curl\/|wget\/|python-requests|python-urllib|scrapy|axios\/|node-fetch|go-http-client|okhttp|java\/|headlesschrome|phantomjs|lighthouse|semrush|ahrefs|mj12|dotbot|petalbot|bytespider|gptbot|claudebot|ccbot/i;
/**
* A missing user agent counts as a bot. Every real browser sends one, so the
* blank case is a script that did not bother.
*/
export function isLikelyBot(userAgent: string | null | undefined): boolean {
if (typeof userAgent !== 'string') return true;
const value = userAgent.trim();
if (!value) return true;
return BOT_PATTERN.test(value);
}
/**
* Whether a request is a real page load rather than a prefetch, an asset or a
* client-side navigation payload.
*
* Next prefetches the register page as soon as a CTA scrolls into view, so
* without this the funnel would show more signup starts than landing views.
*/
export function isCountableDocumentRequest(headers: Headers): boolean {
if (headers.get('sec-purpose')?.includes('prefetch')) return false;
if (headers.get('purpose') === 'prefetch') return false;
if (headers.get('next-router-prefetch')) return false;
// An RSC navigation is the same visitor moving inside the app, not a new view.
if (headers.get('rsc')) return false;
const dest = headers.get('sec-fetch-dest');
if (dest) return dest === 'document';
// Older browsers and anything behind a proxy that strips fetch metadata.
return headers.get('accept')?.includes('text/html') ?? false;
}
+225
View File
@@ -0,0 +1,225 @@
// Turns whatever the browser told us about a visit into one of nine buckets.
//
// Pure and dependency-free on purpose: this runs in the proxy (edge runtime), so
// the only Prisma reference here is a type-only import, which the compiler erases.
//
// Everything that reaches this file is already reduced to a host and a couple of
// UTM tags. Nothing here ever sees a full URL, so a query string carrying a share
// token or an email address cannot be classified into a stored column by mistake.
import type { AcquisitionChannel } from '@prisma/client';
export interface ChannelInput {
utmSource?: string | null;
utmMedium?: string | null;
referrerHost?: string | null;
}
const MAX_TAG_LENGTH = 64;
const MAX_PATH_LENGTH = 128;
/** Lowercases, trims and caps a UTM tag. Returns null for anything empty. */
export function sanitizeTag(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const cleaned = value.trim().toLowerCase().slice(0, MAX_TAG_LENGTH);
if (!cleaned) return null;
// Campaign names are ours, so anything outside this set is either a typo or
// somebody probing what the column accepts. Drop it rather than store it.
if (!/^[a-z0-9._%+\- ]+$/.test(cleaned)) return null;
return cleaned;
}
/**
* Strips the `www.` prefix and the port, lowercases, and caps the length.
*
* A whole URL is not a host and comes back null. Splitting on the first colon
* would otherwise turn `https://github.com` into the host `https`, and every
* caller that passed one by mistake would file its traffic under a channel that
* does not exist.
*/
export function normalizeHost(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim().toLowerCase();
const withoutPort = trimmed.replace(/:\d+$/, '');
const host = withoutPort.replace(/^www\./, '');
if (!host || !/^[a-z0-9.\-]+$/.test(host)) return null;
return host.slice(0, MAX_TAG_LENGTH);
}
/**
* The referring host, or null when there is no usable one.
*
* A referrer pointing at our own deployment is not a referrer: it is the visitor
* clicking through the site. Treating it as one would file most of the funnel
* under whatever page they happened to start on.
*/
export function extractReferrerHost(
referrer: string | null | undefined,
selfHost?: string | null
): string | null {
if (!referrer) return null;
let host: string | null;
try {
const url = new URL(referrer);
// Browsers only ever send an http(s) referrer. Anything else is a scheme we
// have no host for, such as `android-app://com.example`, and reading its
// opaque body as a domain would invent a referring site.
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
host = normalizeHost(url.hostname);
} catch {
return null;
}
if (!host) return null;
const self = normalizeHost(selfHost);
if (self && host === self) return null;
return host;
}
// What a URL path is allowed to be made of, per RFC 3986: unreserved characters,
// percent escapes, sub-delims and the separators. Everything a real route can
// carry, and nothing that survives being pasted into a page or a log line.
const LANDING_PATH_PATTERN = /^\/[A-Za-z0-9\-._~%!$&'()*+,;=:@/]*$/;
/**
* Path only, no query string and no fragment, capped and character-checked.
*
* The proxy feeds this `request.nextUrl.pathname`, which is already a path. The
* cookie reader feeds it whatever the cookie said, which is why the allowlist is
* here rather than left to the caller: an unchecked value would put newlines and
* markup into a column that some later admin table renders.
*/
export function sanitizeLandingPath(pathname: string | null | undefined): string {
if (typeof pathname !== 'string' || !pathname.startsWith('/')) return '/';
const path = (pathname.split('?')[0]?.split('#')[0] ?? '/').slice(0, MAX_PATH_LENGTH);
if (!path || !LANDING_PATH_PATTERN.test(path)) return '/';
return path;
}
function suffixMatch(host: string, domain: string): boolean {
return host === domain || host.endsWith(`.${domain}`);
}
const GITHUB_HOSTS = ['github.com', 'github.blog'];
const YOUTUBE_HOSTS = ['youtube.com', 'youtu.be'];
// The bucket the plan calls "google" is really organic search. Google is the
// overwhelming majority of it, and splitting Bing and DuckDuckGo into their own
// slivers would make every row in the scoreboard smaller without changing a
// single decision.
const SEARCH_HOSTS = ['google.com', 'bing.com', 'duckduckgo.com', 'ecosia.org', 'yandex.com'];
const REVIEW_HOSTS = [
'producthunt.com',
'g2.com',
'capterra.com',
'getapp.com',
'alternativeto.net',
'saashub.com',
'slant.co',
'trustpilot.com',
'sourceforge.net',
];
const COMMUNITY_HOSTS = [
'reddit.com',
'news.ycombinator.com',
'lobste.rs',
'discord.com',
'discord.gg',
'x.com',
'twitter.com',
't.co',
'linkedin.com',
'lnkd.in',
'bsky.app',
'mastodon.social',
'dev.to',
'indiehackers.com',
'facebook.com',
'instagram.com',
't.me',
];
// utm_source values we set ourselves, plus the ones other people tend to use
// when they link us. Matched exactly after sanitizing.
const SOURCE_NAMES: ReadonlyMap<string, AcquisitionChannel> = new Map([
['github', 'GITHUB'],
['youtube', 'YOUTUBE'],
['yt', 'YOUTUBE'],
['google', 'GOOGLE'],
['bing', 'GOOGLE'],
['duckduckgo', 'GOOGLE'],
['producthunt', 'REVIEW_LINK'],
['product-hunt', 'REVIEW_LINK'],
['g2', 'REVIEW_LINK'],
['capterra', 'REVIEW_LINK'],
['alternativeto', 'REVIEW_LINK'],
['reddit', 'COMMUNITY'],
['hackernews', 'COMMUNITY'],
['hn', 'COMMUNITY'],
['discord', 'COMMUNITY'],
['twitter', 'COMMUNITY'],
['x', 'COMMUNITY'],
['linkedin', 'COMMUNITY'],
['newsletter', 'OUTBOUND'],
['coldmail', 'OUTBOUND'],
['outreach', 'OUTBOUND'],
]);
// A medium that names the motion beats the source that names the place: an
// outbound campaign sent from a LinkedIn account is outbound, not community.
const MEDIUM_NAMES: ReadonlyMap<string, AcquisitionChannel> = new Map([
['outbound', 'OUTBOUND'],
['email', 'OUTBOUND'],
['cold-email', 'OUTBOUND'],
['coldemail', 'OUTBOUND'],
['dm', 'OUTBOUND'],
['referral', 'REFERRAL'],
['affiliate', 'REFERRAL'],
]);
function classifyHost(host: string): AcquisitionChannel | null {
if (GITHUB_HOSTS.some((domain) => suffixMatch(host, domain))) return 'GITHUB';
if (YOUTUBE_HOSTS.some((domain) => suffixMatch(host, domain))) return 'YOUTUBE';
// google.co.uk, google.de and the rest: the country domains all sit under a
// `google.<tld>` label, so match the label rather than listing 190 domains.
if (/(^|\.)google\.[a-z.]{2,6}$/.test(host)) return 'GOOGLE';
if (SEARCH_HOSTS.some((domain) => suffixMatch(host, domain))) return 'GOOGLE';
if (REVIEW_HOSTS.some((domain) => suffixMatch(host, domain))) return 'REVIEW_LINK';
if (COMMUNITY_HOSTS.some((domain) => suffixMatch(host, domain))) return 'COMMUNITY';
return null;
}
/**
* The bucket a visit belongs to.
*
* Precedence: an explicit medium that names the motion, then an explicit source,
* then the referring host, then direct. A tagged campaign we do not recognise is
* OTHER rather than DIRECT, because somebody deliberately tagged it.
*
* An unrecognised site that links to us counts as REFERRAL. The raw host is
* stored alongside, so a host that turns out to matter can be promoted into one
* of the lists above and re-read from history.
*/
export function classifyChannel(input: ChannelInput): AcquisitionChannel {
const source = sanitizeTag(input.utmSource);
const medium = sanitizeTag(input.utmMedium);
const host = normalizeHost(input.referrerHost);
const byMedium = medium ? MEDIUM_NAMES.get(medium) : undefined;
if (byMedium) return byMedium;
if (source) {
const bySource = SOURCE_NAMES.get(source);
if (bySource) return bySource;
// A source that looks like a domain (utm_source=github.com) is worth reading
// as one before giving up on it.
return classifyHost(source) ?? 'OTHER';
}
if (host) {
return classifyHost(host) ?? 'REFERRAL';
}
return 'DIRECT';
}
+180
View File
@@ -0,0 +1,180 @@
// The two cookies the acquisition system sets, and how to read them back.
//
// Both are first party, both stay on this deployment's own domain, and neither
// is readable from JavaScript. They exist so that a visitor who arrives from a
// YouTube link on Tuesday and signs up on Friday is still counted against
// YouTube; there is no cross-site identifier and nothing is sent anywhere.
//
// Both are also signed. Nothing here trusts a cookie it did not issue: read
// through `readAnonymousIdCookie` and `readFirstTouchCookie`, never through the
// `decode` helpers, which are the unsigned inner layer.
//
// Imported by the proxy, so this file must stay free of Prisma and of anything
// else that cannot run on the edge.
import type { AcquisitionChannel } from '@prisma/client';
import { sanitizeLandingPath, sanitizeTag, normalizeHost } from '@/lib/analytics/channel';
import { signCookieValue, unsignCookieValue } from '@/lib/analytics/signing';
export const ANONYMOUS_ID_COOKIE = 'of_aid';
export const FIRST_TOUCH_COOKIE = 'of_ft';
export const ANONYMOUS_ID_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
/** cuid-ish length bound. Values outside it are treated as absent, not repaired. */
const ANONYMOUS_ID_PATTERN = /^[a-z0-9]{16,64}$/;
export interface FirstTouch {
channel: AcquisitionChannel;
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
referrerHost: string | null;
landingPath: string;
}
/** Short keys: this rides on every request, so the wire form stays compact. */
interface EncodedFirstTouch {
c: string;
s?: string;
m?: string;
k?: string;
r?: string;
p: string;
}
const CHANNELS: readonly AcquisitionChannel[] = [
'DIRECT',
'GITHUB',
'YOUTUBE',
'GOOGLE',
'REVIEW_LINK',
'REFERRAL',
'OUTBOUND',
'COMMUNITY',
'OTHER',
];
export function isAcquisitionChannel(value: unknown): value is AcquisitionChannel {
return typeof value === 'string' && (CHANNELS as readonly string[]).includes(value);
}
export function isValidAnonymousId(value: string | null | undefined): value is string {
return typeof value === 'string' && ANONYMOUS_ID_PATTERN.test(value);
}
/** 128 bits from the Web Crypto API, which the edge has, as 32 base36 characters. */
export function generateAnonymousId(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
let id = '';
for (const byte of bytes) {
id += byte.toString(36).padStart(2, '0');
}
return id;
}
// base64url rather than encodeURIComponent, and not for compactness. Cookie
// values are percent-encoded on the way out and decoded on the way back, by
// several layers that do not all agree on how many times; a payload that already
// contains percent escapes comes back subtly different and takes the signature
// down with it. base64url has nothing either layer wants to touch.
function toBase64Url(text: string): string {
let binary = '';
for (const byte of new TextEncoder().encode(text)) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function fromBase64Url(value: string): string | null {
try {
const padded = value.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, '='));
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
return new TextDecoder().decode(bytes);
} catch {
return null;
}
}
export function encodeFirstTouch(touch: FirstTouch): string {
const payload: EncodedFirstTouch = { c: touch.channel, p: touch.landingPath };
if (touch.utmSource) payload.s = touch.utmSource;
if (touch.utmMedium) payload.m = touch.utmMedium;
if (touch.utmCampaign) payload.k = touch.utmCampaign;
if (touch.referrerHost) payload.r = touch.referrerHost;
return toBase64Url(JSON.stringify(payload));
}
/**
* Parses the cookie body back, re-sanitizing every field.
*
* The second line of defence, not the first: callers go through
* `readFirstTouchCookie`, which checks the signature before this ever runs. The
* re-sanitizing stays because a value that survives both checks can still be one
* this deployment signed a year ago, under an older set of rules. Anything that
* fails validation makes the whole value null, since a half-trusted first touch
* is worse than none.
*/
export function decodeFirstTouch(raw: string | null | undefined): FirstTouch | null {
if (!raw) return null;
const json = fromBase64Url(raw);
if (!json) return null;
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return null;
}
if (!parsed || typeof parsed !== 'object') return null;
const value = parsed as Record<string, unknown>;
if (!isAcquisitionChannel(value.c)) return null;
if (typeof value.p !== 'string') return null;
return {
channel: value.c,
utmSource: sanitizeTag(typeof value.s === 'string' ? value.s : null),
utmMedium: sanitizeTag(typeof value.m === 'string' ? value.m : null),
utmCampaign: sanitizeTag(typeof value.k === 'string' ? value.k : null),
referrerHost: normalizeHost(typeof value.r === 'string' ? value.r : null),
landingPath: sanitizeLandingPath(value.p),
};
}
// ---------------------------------------------------------------------------
// The signed forms, which are the only ones anything outside this file uses.
// ---------------------------------------------------------------------------
/** The cookie value to set, or null when there is no secret to sign it with. */
export function signAnonymousId(anonymousId: string): Promise<string | null> {
return signCookieValue(anonymousId);
}
export function signFirstTouch(touch: FirstTouch): Promise<string | null> {
return signCookieValue(encodeFirstTouch(touch));
}
/**
* The anonymous id this deployment issued, or null.
*
* Null covers every failure the same way: no cookie, a cookie signed with
* another key, one edited by hand, one whose id no longer matches the shape we
* mint. A visitor we cannot vouch for is not counted rather than counted wrong.
*/
export async function readAnonymousIdCookie(
raw: string | null | undefined
): Promise<string | null> {
const anonymousId = await unsignCookieValue(raw);
return isValidAnonymousId(anonymousId) ? anonymousId : null;
}
export async function readFirstTouchCookie(
raw: string | null | undefined
): Promise<FirstTouch | null> {
return decodeFirstTouch(await unsignCookieValue(raw));
}
+183
View File
@@ -0,0 +1,183 @@
// The only way anything in this repo writes an analytics row.
//
// Two things are centralised here so that no call site has to remember them:
//
// 1. The feature flag. Every function below returns without touching the
// database when OPENFRAME_ENABLE_ANALYTICS is off, which is why the ~15 call
// sites scattered through app/api are unconditional one-liners.
// 2. Failure. Measurement must never be able to fail a product request, so
// every write is caught and logged. An event that is not recorded is a hole
// in a chart; an event that throws is a user who cannot create a project.
import type { AcquisitionChannel, AnalyticsEventName } from '@prisma/client';
import { db } from '@/lib/db';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
import { logError } from '@/lib/logger';
import type { FirstTouch } from '@/lib/analytics/cookies';
export interface RecordEventInput {
name: AnalyticsEventName;
/**
* What makes this event unique. The column is UNIQUE, so a replayed webhook, a
* double-submitted form or a refreshed page collide here and the second write
* is dropped by the database rather than by a check somebody might forget.
*/
dedupeKey: string;
userId?: string | null;
anonymousId?: string | null;
/**
* Only set for events that happen before there is an account. Once a user
* exists their channel lives in user_acquisitions and the scoreboard reads it
* from there, so a later correction applies to their whole history.
*/
channel?: AcquisitionChannel | null;
occurredAt?: Date;
}
/** `<event>:<subject>`, for something that can only ever happen once per subject. */
export function eventKey(name: AnalyticsEventName, subject: string): string {
return `${name}:${subject}`;
}
/**
* `<event>:<subject>:<UTC day>`, for something repeatable that should still count
* once per visitor per day (a landing view, a CTA click).
*/
export function dailyEventKey(
name: AnalyticsEventName,
subject: string,
at: Date = new Date()
): string {
return `${name}:${subject}:${at.toISOString().slice(0, 10)}`;
}
export async function recordEvent(input: RecordEventInput): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
try {
await db.analyticsEvent.createMany({
data: [
{
name: input.name,
dedupeKey: input.dedupeKey,
userId: input.userId ?? null,
anonymousId: input.anonymousId ?? null,
channel: input.channel ?? null,
...(input.occurredAt ? { occurredAt: input.occurredAt } : {}),
},
],
skipDuplicates: true,
});
} catch (error) {
logError('Failed to record analytics event:', error);
}
}
/**
* Stores the first touch for a visitor with no account yet.
*
* Never updated. A visitor who comes back a week later through a different link
* keeps the channel that brought them the first time, which is the question the
* scoreboard is asking.
*/
export async function recordFirstTouch(anonymousId: string, touch: FirstTouch): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
try {
await db.acquisitionTouch.createMany({
data: [
{
anonymousId,
channel: touch.channel,
utmSource: touch.utmSource,
utmMedium: touch.utmMedium,
utmCampaign: touch.utmCampaign,
referrerHost: touch.referrerHost,
landingPath: touch.landingPath,
},
],
skipDuplicates: true,
});
} catch (error) {
logError('Failed to record acquisition touch:', error);
}
}
/**
* Copies the first touch onto a freshly created account and claims the events
* that visitor produced before they had one.
*
* The backfill is what joins the two halves of the funnel: without it a landing
* view and the signup it led to are two unrelated rows, and no query can tell
* you that GitHub traffic converts and Google traffic does not.
*/
export async function attachAcquisitionToUser(params: {
userId: string;
anonymousId: string | null;
touch: FirstTouch | null;
}): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
const { userId, anonymousId, touch } = params;
try {
await db.userAcquisition.createMany({
data: [
{
userId,
anonymousId,
channel: touch?.channel ?? 'DIRECT',
utmSource: touch?.utmSource ?? null,
utmMedium: touch?.utmMedium ?? null,
utmCampaign: touch?.utmCampaign ?? null,
referrerHost: touch?.referrerHost ?? null,
landingPath: touch?.landingPath ?? null,
},
],
skipDuplicates: true,
});
if (anonymousId) {
await db.analyticsEvent.updateMany({
where: { anonymousId, userId: null },
data: { userId },
});
}
} catch (error) {
logError('Failed to attach acquisition to user:', error);
}
}
/**
* The answer to the onboarding question, which is a check on the cookie rather
* than a replacement for it: it survives a cleared cookie and a phone-to-laptop
* switch, and it is the only signal that can catch a channel the UTM tags miss
* entirely ("a friend told me").
*/
export async function setSelfReportedSource(params: {
userId: string;
selfReported: AcquisitionChannel;
note?: string | null;
}): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
const note = params.note?.trim().slice(0, 200) || null;
try {
await db.userAcquisition.upsert({
where: { userId: params.userId },
create: {
userId: params.userId,
channel: 'DIRECT',
selfReported: params.selfReported,
selfReportedNote: note,
},
update: {
selfReported: params.selfReported,
selfReportedNote: note,
},
});
} catch (error) {
logError('Failed to store self-reported acquisition source:', error);
}
}
+341
View File
@@ -0,0 +1,341 @@
// The Monday scoreboard, as queries.
//
// Two decisions here are worth stating, because they are what make the numbers
// readable rather than merely present:
//
// 1. Rates, not just counts. A funnel is a set of ratios; the step with the
// worst ratio is the thing to fix, and a column of absolute numbers hides it.
// 2. Every rate carries its denominator. At this volume a weekly per-channel
// cell holds single digits, and 1 out of 3 renders as "33%" exactly as
// confidently as 340 out of 1020. The channel view therefore runs on a
// rolling 28-day window rather than a week, and still reports `n`.
import type { AcquisitionChannel } from '@prisma/client';
import { db } from '@/lib/db';
import { getCachedStripeStats } from '@/lib/admin-stats';
/** What "using the product" means for a paying account. */
export const VALUE_EVENT_NAMES = [
'VIDEO_ADDED',
'SHARE_LINK_CREATED',
'FIRST_GUEST_COMMENT',
'APPROVAL_COMPLETED',
'PROJECT_CREATED',
] as const;
/** A paid account that has produced nothing for this long is drifting away. */
export const AT_RISK_SILENT_DAYS = 14;
const DEFAULT_WEEKS = 12;
const CHANNEL_WINDOW_DAYS = 28;
/**
* How many paid accounts the per-account table carries.
*
* The list is ordered quietest first, so the cap drops the accounts that are
* using the product most, which are the ones nobody needs to read a row about.
* It is reported rather than applied silently: a truncated table that looks
* complete is worse than a smaller one that says so.
*/
const PAID_ACCOUNT_LIMIT = 500;
export interface WeeklyRow {
weekStart: Date;
visitors: number;
ctaClicks: number;
signupStarted: number;
signups: number;
emailVerified: number;
firstVideo: number;
shareLinks: number;
externalFeedback: number;
trials: number;
newPaid: number;
canceled: number;
/** Running net of started minus canceled. Derived, not a Stripe snapshot. */
activePaid: number;
mrrCents: number;
}
export interface ChannelRow {
channel: AcquisitionChannel;
visitors: number;
signups: number;
trials: number;
paid: number;
}
export interface PaidAccountRow {
userId: string;
name: string | null;
email: string | null;
status: string;
valueEvents7: number;
valueEvents30: number;
lastValueEventAt: Date | null;
channel: AcquisitionChannel | null;
selfReported: AcquisitionChannel | null;
}
export interface Scoreboard {
weeks: WeeklyRow[];
channels: ChannelRow[];
channelWindowDays: number;
paidAccounts: PaidAccountRow[];
/** True when there are more paid accounts than the table shows. */
paidAccountsTruncated: boolean;
paidAccountLimit: number;
atRisk: PaidAccountRow[];
currentActivePaid: number | null;
currentMrrCents: number | null;
currency: string;
}
interface WeeklyQueryRow {
week: Date;
name: string;
subjects: number;
}
interface ChannelQueryRow {
channel: AcquisitionChannel | null;
name: string;
subjects: number;
}
interface PaidQueryRow {
user_id: string;
name: string | null;
email: string | null;
status: string;
channel: AcquisitionChannel | null;
self_reported: AcquisitionChannel | null;
value_events_7: number;
value_events_30: number;
last_value_event_at: Date | null;
}
function startOfWeek(date: Date): Date {
const copy = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0)
);
// Postgres date_trunc('week') starts on Monday; match it so the two halves of
// the table line up.
const isoDayIndex = (copy.getUTCDay() + 6) % 7;
copy.setUTCDate(copy.getUTCDate() - isoDayIndex);
return copy;
}
function emptyWeek(weekStart: Date): WeeklyRow {
return {
weekStart,
visitors: 0,
ctaClicks: 0,
signupStarted: 0,
signups: 0,
emailVerified: 0,
firstVideo: 0,
shareLinks: 0,
externalFeedback: 0,
trials: 0,
newPaid: 0,
canceled: 0,
activePaid: 0,
mrrCents: 0,
};
}
const WEEK_COLUMN_BY_EVENT: Record<string, keyof WeeklyRow> = {
LANDING_VIEW: 'visitors',
CTA_CLICKED: 'ctaClicks',
SIGNUP_STARTED: 'signupStarted',
SIGNUP_COMPLETED: 'signups',
EMAIL_VERIFIED: 'emailVerified',
VIDEO_ADDED: 'firstVideo',
SHARE_LINK_CREATED: 'shareLinks',
FIRST_GUEST_COMMENT: 'externalFeedback',
TRIAL_STARTED: 'trials',
SUBSCRIPTION_STARTED: 'newPaid',
SUBSCRIPTION_CANCELED: 'canceled',
};
export interface FunnelRates {
visitorToSignup: number | null;
signupToFirstVideo: number | null;
firstVideoToShare: number | null;
shareToFeedback: number | null;
trialToPaid: number | null;
}
/**
* Step-to-step conversion, or null when the denominator is zero.
*
* Null rather than 0 on purpose: "no visitors, so no rate" and "visitors, none
* of whom converted" are different facts, and showing the first as 0% invents a
* problem that is not there.
*/
export function conversionRates(row: {
visitors: number;
signups: number;
firstVideo: number;
shareLinks: number;
externalFeedback: number;
trials: number;
newPaid: number;
}): FunnelRates {
const ratio = (numerator: number, denominator: number) =>
denominator > 0 ? numerator / denominator : null;
return {
visitorToSignup: ratio(row.signups, row.visitors),
signupToFirstVideo: ratio(row.firstVideo, row.signups),
firstVideoToShare: ratio(row.shareLinks, row.firstVideo),
shareToFeedback: ratio(row.externalFeedback, row.shareLinks),
trialToPaid: ratio(row.newPaid, row.trials),
};
}
export async function getScoreboard(options?: { weeks?: number }): Promise<Scoreboard> {
const weeks = Math.min(Math.max(options?.weeks ?? DEFAULT_WEEKS, 1), 52);
const now = new Date();
const firstWeekStart = startOfWeek(now);
firstWeekStart.setUTCDate(firstWeekStart.getUTCDate() - (weeks - 1) * 7);
const channelWindowStart = new Date(now);
channelWindowStart.setUTCDate(channelWindowStart.getUTCDate() - CHANNEL_WINDOW_DAYS);
const [weekRows, channelRows, priorPaid, paidAccounts, stripeStats] = await Promise.all([
// COUNT(DISTINCT COALESCE(anonymous_id, id)) rather than COUNT(*): a landing
// view is deduped per visitor per day, so a visitor who came back on three
// days would otherwise be three weekly visitors. Rows with no anonymous id
// fall back to their own primary key and stay distinct.
db.$queryRaw<WeeklyQueryRow[]>`
SELECT date_trunc('week', occurred_at) AS week,
name::text AS name,
COUNT(DISTINCT COALESCE(anonymous_id, id))::int AS subjects
FROM analytics_events
WHERE occurred_at >= ${firstWeekStart}
GROUP BY 1, 2
`,
db.$queryRaw<ChannelQueryRow[]>`
SELECT COALESCE(ua.channel, e.channel) AS channel,
e.name::text AS name,
COUNT(DISTINCT COALESCE(e.anonymous_id, e.id))::int AS subjects
FROM analytics_events e
LEFT JOIN user_acquisitions ua ON ua.user_id = e.user_id
WHERE e.occurred_at >= ${channelWindowStart}
GROUP BY 1, 2
`,
db.$queryRaw<Array<{ started: number; canceled: number }>>`
SELECT
COUNT(*) FILTER (WHERE name::text = 'SUBSCRIPTION_STARTED')::int AS started,
COUNT(*) FILTER (WHERE name::text = 'SUBSCRIPTION_CANCELED')::int AS canceled
FROM analytics_events
WHERE occurred_at < ${firstWeekStart}
`,
db.$queryRaw<PaidQueryRow[]>`
SELECT u.id AS user_id,
u.name,
u.email,
u."subscriptionStatus"::text AS status,
ua.channel,
ua.self_reported,
COUNT(e.id) FILTER (WHERE e.occurred_at >= NOW() - INTERVAL '7 days')::int
AS value_events_7,
COUNT(e.id) FILTER (WHERE e.occurred_at >= NOW() - INTERVAL '30 days')::int
AS value_events_30,
MAX(e.occurred_at) AS last_value_event_at
FROM users u
LEFT JOIN user_acquisitions ua ON ua.user_id = u.id
LEFT JOIN analytics_events e
ON e.user_id = u.id
AND e.name::text = ANY(${[...VALUE_EVENT_NAMES]}::text[])
WHERE u."subscriptionStatus"::text IN ('ACTIVE', 'TRIALING')
GROUP BY u.id, u.name, u.email, u."subscriptionStatus", ua.channel, ua.self_reported
ORDER BY MAX(e.occurred_at) ASC NULLS FIRST
LIMIT ${PAID_ACCOUNT_LIMIT + 1}
`,
getCachedStripeStats(),
]);
const byWeek = new Map<number, WeeklyRow>();
for (let index = 0; index < weeks; index += 1) {
const weekStart = new Date(firstWeekStart);
weekStart.setUTCDate(weekStart.getUTCDate() + index * 7);
byWeek.set(weekStart.getTime(), emptyWeek(weekStart));
}
for (const row of weekRows) {
const bucket = byWeek.get(startOfWeek(row.week).getTime());
const column = WEEK_COLUMN_BY_EVENT[row.name];
if (!bucket || !column) continue;
(bucket[column] as number) = row.subjects;
}
// One flat plan, so a per-subscription price is enough to turn a subscriber
// count into MRR. Taken from Stripe rather than hardcoded, and zero when
// billing is not configured at all.
const unitAmountCents =
stripeStats && stripeStats.activeSubscribers > 0
? Math.round(stripeStats.mrrCents / stripeStats.activeSubscribers)
: 0;
let running = (priorPaid[0]?.started ?? 0) - (priorPaid[0]?.canceled ?? 0);
const orderedWeeks = [...byWeek.values()].sort(
(a, b) => a.weekStart.getTime() - b.weekStart.getTime()
);
for (const week of orderedWeeks) {
running += week.newPaid - week.canceled;
week.activePaid = Math.max(running, 0);
week.mrrCents = week.activePaid * unitAmountCents;
}
const channelBuckets = new Map<AcquisitionChannel, ChannelRow>();
for (const row of channelRows) {
const channel = row.channel ?? 'OTHER';
const bucket = channelBuckets.get(channel) ?? {
channel,
visitors: 0,
signups: 0,
trials: 0,
paid: 0,
};
if (row.name === 'LANDING_VIEW') bucket.visitors += row.subjects;
if (row.name === 'SIGNUP_COMPLETED') bucket.signups += row.subjects;
if (row.name === 'TRIAL_STARTED') bucket.trials += row.subjects;
if (row.name === 'SUBSCRIPTION_STARTED') bucket.paid += row.subjects;
channelBuckets.set(channel, bucket);
}
// One row over the limit was fetched purely to tell "exactly full" from "cut off".
const paidAccountsTruncated = paidAccounts.length > PAID_ACCOUNT_LIMIT;
const accounts: PaidAccountRow[] = paidAccounts.slice(0, PAID_ACCOUNT_LIMIT).map((row) => ({
userId: row.user_id,
name: row.name,
email: row.email,
status: row.status,
channel: row.channel,
selfReported: row.self_reported,
valueEvents7: row.value_events_7,
valueEvents30: row.value_events_30,
lastValueEventAt: row.last_value_event_at,
}));
const silentBefore = new Date(now);
silentBefore.setUTCDate(silentBefore.getUTCDate() - AT_RISK_SILENT_DAYS);
return {
weeks: orderedWeeks,
channels: [...channelBuckets.values()].sort((a, b) => b.visitors - a.visitors),
channelWindowDays: CHANNEL_WINDOW_DAYS,
paidAccounts: accounts,
paidAccountsTruncated,
paidAccountLimit: PAID_ACCOUNT_LIMIT,
atRisk: accounts.filter(
(account) => !account.lastValueEventAt || account.lastValueEventAt < silentBefore
),
currentActivePaid: stripeStats?.activeSubscribers ?? null,
currentMrrCents: stripeStats?.mrrCents ?? null,
currency: stripeStats?.currency ?? 'usd',
};
}
+115
View File
@@ -0,0 +1,115 @@
// Signing for the two acquisition cookies.
//
// httpOnly keeps JavaScript out of these cookies. It does nothing about curl,
// and both cookies are read straight into database columns, so without a
// signature the anonymous id is simply a string the caller picked. Picking one
// is enough to write a first-touch row for a visitor who never existed, or to
// claim another visitor's events at signup, since the backfill matches on the
// id alone.
//
// Web Crypto rather than node:crypto: this is imported by the proxy, which runs
// on the edge, and by the pages that read the cookies back, which run in Node.
// Both have crypto.subtle; only Node has createHmac.
import { logWarn } from '@/lib/logger';
const SEPARATOR = '.';
/**
* 132 bits of an HMAC-SHA256, base64url. Truncating a MAC is standard practice
* and keeps a cookie that rides on every request small.
*/
const SIGNATURE_LENGTH = 22;
let cachedSecret: string | null = null;
let cachedKey: Promise<CryptoKey> | null = null;
let warnedAboutMissingSecret = false;
function readSecret(): string | null {
const secret = process.env.AUTH_SECRET?.trim() || process.env.NEXTAUTH_SECRET?.trim();
if (secret) return secret;
// Not thrown. The proxy runs on every request and the pages render for every
// visitor; failing those to protect a funnel chart would be the wrong trade.
// Analytics simply records nothing, which is visible on /admin/growth the same
// day, and it is announced once per process rather than per request.
if (!warnedAboutMissingSecret) {
warnedAboutMissingSecret = true;
logWarn(
'AUTH_SECRET (or NEXTAUTH_SECRET) is not set, so acquisition cookies cannot be ' +
'signed. Nothing will be recorded while it is missing.'
);
}
return null;
}
function getKey(secret: string): Promise<CryptoKey> {
if (!cachedKey || cachedSecret !== secret) {
cachedSecret = secret;
cachedKey = crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
}
return cachedKey;
}
function toBase64Url(buffer: ArrayBuffer): string {
let binary = '';
for (const byte of new Uint8Array(buffer)) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function macOf(value: string, secret: string): Promise<string> {
const signature = await crypto.subtle.sign(
'HMAC',
await getKey(secret),
new TextEncoder().encode(value)
);
return toBase64Url(signature).slice(0, SIGNATURE_LENGTH);
}
/** Constant time, so a forged cookie learns nothing from how long it took to reject. */
function equals(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let difference = 0;
for (let index = 0; index < a.length; index += 1) {
difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
}
return difference === 0;
}
/**
* `<mac>.<value>`.
*
* The MAC goes first and is fixed-length, so the split is a slice at a known
* offset rather than a search for a separator that a future payload might
* happen to contain.
*
* Returns null when there is no secret to sign with, which the callers treat as
* "set no cookie".
*/
export async function signCookieValue(value: string): Promise<string | null> {
const secret = readSecret();
if (!secret) return null;
return `${await macOf(value, secret)}${SEPARATOR}${value}`;
}
/** The signed value back, or null if it was absent, truncated, or edited. */
export async function unsignCookieValue(signed: string | null | undefined): Promise<string | null> {
if (typeof signed !== 'string' || signed.length <= SIGNATURE_LENGTH + 1) return null;
const secret = readSecret();
if (!secret) return null;
if (signed[SIGNATURE_LENGTH] !== SEPARATOR) return null;
const mac = signed.slice(0, SIGNATURE_LENGTH);
const value = signed.slice(SIGNATURE_LENGTH + 1);
return equals(mac, await macOf(value, secret)) ? value : null;
}
+46
View File
@@ -0,0 +1,46 @@
// Signup is the seam where an anonymous visitor becomes an account, so it is the
// one place the two halves of the funnel are joined. Both ways of creating an
// account (the credentials form and an OAuth provider) go through here, because
// a channel that only shows up for one of them is worse than no channel at all.
import { eventKey, recordEvent, attachAcquisitionToUser } from '@/lib/analytics/record';
import { NO_VISITOR, readVisitorContext, type VisitorContext } from '@/lib/analytics/visitor';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
/**
* The visitor context of the request being handled.
*
* For the OAuth path there is no NextRequest to read: the account is created by
* the adapter, from inside a NextAuth event. `cookies()` still resolves there,
* and when it does not the signup is simply recorded without a channel rather
* than not recorded at all.
*/
export async function readVisitorContextFromHeaders(): Promise<VisitorContext> {
if (!isProductAnalyticsEnabled()) return NO_VISITOR;
try {
const { cookies, headers } = await import('next/headers');
return await readVisitorContext(await cookies(), await headers());
} catch {
return NO_VISITOR;
}
}
export async function recordSignupCompleted(params: {
userId: string;
visitor: VisitorContext;
}): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
await attachAcquisitionToUser({
userId: params.userId,
anonymousId: params.visitor.anonymousId,
touch: params.visitor.firstTouch,
});
await recordEvent({
name: 'SIGNUP_COMPLETED',
dedupeKey: eventKey('SIGNUP_COMPLETED', params.userId),
userId: params.userId,
anonymousId: params.visitor.anonymousId,
});
}
+159
View File
@@ -0,0 +1,159 @@
// Reading the acquisition cookies, and recording the events that happen before
// an account exists.
//
// The proxy sets the cookies but cannot write rows (it runs on the edge). These
// helpers close that gap from Node, and are the reason no visitor event depends
// on client-side JavaScript running: a landing view is recorded by the server
// rendering the landing page, so an ad blocker has nothing to block. That is not
// a purity argument. Blocking rates differ by channel, and an undercounted
// denominator would make GitHub and Hacker News traffic look like it converts
// better than it does.
//
// Recording server-side also means an anonymous request can write rows, so the
// three filters that decide whether a request counts live here rather than only
// in the proxy: the signature on the cookie, the bot and prefetch checks, and a
// per-client ceiling. In the proxy they only govern which cookies get issued,
// which is not the same thing as which rows get written.
import type { AnalyticsEventName } from '@prisma/client';
import {
ANONYMOUS_ID_COOKIE,
FIRST_TOUCH_COOKIE,
readAnonymousIdCookie,
readFirstTouchCookie,
type FirstTouch,
} from '@/lib/analytics/cookies';
import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots';
import { dailyEventKey, recordEvent, recordFirstTouch } from '@/lib/analytics/record';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
import {
RATE_LIMIT_CONFIGS,
checkRateLimit,
getClientIpFromHeaders,
isClientIpTrustworthy,
} from '@/lib/rate-limit';
/** Both `cookies()` from next/headers and `request.cookies` satisfy this. */
export interface AnalyticsCookieReader {
get(name: string): { value: string } | undefined;
}
export interface VisitorContext {
anonymousId: string | null;
firstTouch: FirstTouch | null;
/** Carried so the ceiling below can be applied after the response, not during it. */
clientIp: string | null;
}
export const NO_VISITOR: VisitorContext = { anonymousId: null, firstTouch: null, clientIp: null };
/**
* The visitor behind a set of cookies, or an empty context.
*
* Both cookies are verified, so an id that reaches a database column is one this
* deployment issued. A forged one is not repaired or partially trusted, it is
* simply not a visitor.
*/
export async function readVisitorContext(
store: AnalyticsCookieReader,
headers?: Headers
): Promise<VisitorContext> {
if (!isProductAnalyticsEnabled()) return NO_VISITOR;
const anonymousId = await readAnonymousIdCookie(store.get(ANONYMOUS_ID_COOKIE)?.value);
if (!anonymousId) return NO_VISITOR;
return {
anonymousId,
firstTouch: await readFirstTouchCookie(store.get(FIRST_TOUCH_COOKIE)?.value),
clientIp: headers ? getClientIpFromHeaders(headers) : null,
};
}
/**
* The visitor behind a page render.
*
* Applies the same two header checks the proxy does, because they mean different
* things in the two places. In the proxy they decide who gets a cookie; here they
* decide what counts. A returning visitor already holds a cookie, so without this
* Next prefetching /register as a CTA scrolls into view would record a signup
* start for a page nobody opened.
*/
export async function readPageVisitor(): Promise<VisitorContext> {
if (!isProductAnalyticsEnabled()) return NO_VISITOR;
const { cookies, headers } = await import('next/headers');
const requestHeaders = await headers();
if (!isCountableDocumentRequest(requestHeaders)) return NO_VISITOR;
if (isLikelyBot(requestHeaders.get('user-agent'))) return NO_VISITOR;
return readVisitorContext(await cookies(), requestHeaders);
}
/**
* The visitor behind an API request.
*
* No document check: a beacon is `sec-fetch-dest: empty` by definition, and the
* routes that call this are reached by a form submission rather than by a
* navigation.
*/
export async function readRequestVisitor(request: {
cookies: AnalyticsCookieReader;
headers: Headers;
}): Promise<VisitorContext> {
if (!isProductAnalyticsEnabled()) return NO_VISITOR;
if (isLikelyBot(request.headers.get('user-agent'))) return NO_VISITOR;
return readVisitorContext(request.cookies, request.headers);
}
const DIRECT_TOUCH: FirstTouch = {
channel: 'DIRECT',
utmSource: null,
utmMedium: null,
utmCampaign: null,
referrerHost: null,
landingPath: '/',
};
/**
* A ceiling on how many visitors one client can invent per hour.
*
* A fresh signed cookie is one request away: drop the cookie, ask for the
* landing page again, and the proxy mints another id. The signature stops a
* caller from choosing an id, and this stops them from collecting an unbounded
* number of real ones. Skipped when the client IP is not real, where the bucket
* would be shared by everybody and would throttle the site rather than the
* flood.
*/
async function withinVisitorCeiling(clientIp: string | null): Promise<boolean> {
if (!clientIp || !isClientIpTrustworthy()) return true;
const config = RATE_LIMIT_CONFIGS['analytics-visitor'];
const result = await checkRateLimit(clientIp, 'analytics-visitor', config);
return result.allowed;
}
/**
* Records an event for a visitor with no account, once per visitor per UTC day.
*
* The first touch row is written here rather than in the proxy because this is
* the first moment the visitor is known to be a browser that kept the cookie.
*/
export async function recordVisitorEvent(
name: AnalyticsEventName,
visitor: VisitorContext
): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
if (!visitor.anonymousId) return;
if (!(await withinVisitorCeiling(visitor.clientIp))) return;
const touch = visitor.firstTouch ?? DIRECT_TOUCH;
await recordFirstTouch(visitor.anonymousId, touch);
await recordEvent({
name,
dedupeKey: dailyEventKey(name, visitor.anonymousId),
anonymousId: visitor.anonymousId,
channel: touch.channel,
});
}
+15
View File
@@ -150,6 +150,21 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
return token;
},
},
events: {
// The OAuth half of signup. Accounts created by Google or GitHub are written
// by the Prisma adapter and never reach app/api/auth/register, so recording
// the event only there would have made every social signup invisible in the
// funnel while looking like it worked.
async createUser({ user }) {
if (!user.id) return;
const { recordSignupCompleted, readVisitorContextFromHeaders } =
await import('@/lib/analytics/signup');
await recordSignupCompleted({
userId: user.id,
visitor: await readVisitorContextFromHeaders(),
});
},
},
});
// ---------------------------------------------------------------------------
+55 -3
View File
@@ -4,6 +4,7 @@ import { BillingSubscriptionStatus } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe, getStripePriceId } from '@/lib/stripe';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { recordSubscriptionTransition } from '@/lib/analytics/billing-events';
const ACTIVE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
BillingSubscriptionStatus.ACTIVE,
@@ -394,6 +395,10 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
select: {
id: true,
billingTrialConsumedAt: true,
// Read for the funnel: the transition is what gets recorded, so the state
// being overwritten has to be captured before the update below.
subscriptionStatus: true,
stripeCancelAtPeriodEnd: true,
},
});
@@ -430,7 +435,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
(hasActiveSubscription(mappedStatus) ||
Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now()));
return db.user.update({
const updated = await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
@@ -449,6 +454,24 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
: getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
},
});
await recordSubscriptionTransition({
userId: user.id,
subscriptionId: subscription.id,
before: {
status: user.subscriptionStatus,
cancelAtPeriodEnd: user.stripeCancelAtPeriodEnd,
hadTrial: user.billingTrialConsumedAt !== null,
},
after: {
status: mappedStatus,
cancelAtPeriodEnd,
trialEndsAt: effectiveTrialEnd,
currentPeriodEnd: effectiveCurrentPeriodEnd,
},
});
return updated;
}
// A single Stripe customer can own several subscriptions at once (e.g. after
@@ -524,14 +547,21 @@ export async function markSubscriptionCanceledByCustomerId(
) {
const user = await db.user.findUnique({
where: { stripeCustomerId: customerId },
select: { id: true },
select: {
id: true,
subscriptionStatus: true,
stripeSubscriptionId: true,
stripeCancelAtPeriodEnd: true,
stripeCurrentPeriodEnd: true,
billingTrialConsumedAt: true,
},
});
if (!user) {
return null;
}
return db.user.update({
const updated = await db.user.update({
where: { id: user.id },
data: {
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
@@ -544,4 +574,26 @@ export async function markSubscriptionCanceledByCustomerId(
billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
},
});
// Reached when the customer has no subscriptions left at all. The cycle marker
// uses the period end being cleared here, which is the same one the earlier
// "cancel at period end" write carried, so a customer who cancelled through the
// portal and then reached the end of their term produces one cancellation, not two.
await recordSubscriptionTransition({
userId: user.id,
subscriptionId: user.stripeSubscriptionId ?? user.id,
before: {
status: user.subscriptionStatus,
cancelAtPeriodEnd: user.stripeCancelAtPeriodEnd,
hadTrial: user.billingTrialConsumedAt !== null,
},
after: {
status: BillingSubscriptionStatus.CANCELED,
cancelAtPeriodEnd: false,
trialEndsAt: null,
currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null,
},
});
return updated;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Reports a click on a call to action.
*
* `sendBeacon` rather than a plain fetch because a CTA click navigates away
* immediately, and a request still in flight when the page unloads is cancelled.
*
* Everything here is best effort. This is the only funnel event that depends on
* the browser cooperating, which is also why nothing downstream divides by it:
* the visitor and signup counts either side of it are recorded server-side.
*/
export function trackCtaClick(): void {
if (typeof navigator === 'undefined') return;
const payload = JSON.stringify({ name: 'cta_clicked' });
try {
if (typeof navigator.sendBeacon === 'function') {
navigator.sendBeacon('/api/events', new Blob([payload], { type: 'application/json' }));
return;
}
void fetch('/api/events', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: payload,
keepalive: true,
}).catch(() => undefined);
} catch {
// A click must never fail because measurement did.
}
}
+19
View File
@@ -9,6 +9,8 @@ import {
EMAIL_COLORS,
} from '@/lib/email-brand';
import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
// Reduce window to 2 hours — shorter exposure in access logs and backups.
const TOKEN_EXPIRY_HOURS = 2;
@@ -77,6 +79,23 @@ export async function consumeVerificationToken(token: string): Promise<string |
// Return null so a replayed/stale token never produces a misleading success redirect.
if (user.count === 0) return null;
// Behind the flag so the extra lookup does not happen at all on a deployment
// that is not measuring. count > 0 above already means this is the one call
// that flipped the account, so a replayed link cannot reach here.
if (isProductAnalyticsEnabled()) {
const verified = await db.user.findUnique({
where: { email: record.identifier },
select: { id: true },
});
if (verified) {
await recordEvent({
name: 'EMAIL_VERIFIED',
dedupeKey: eventKey('EMAIL_VERIFIED', verified.id),
userId: verified.id,
});
}
}
return record.identifier;
}
+10
View File
@@ -98,6 +98,16 @@ export function isInviteCodeRequired() {
return readBooleanEnv('OPENFRAME_REQUIRE_INVITE_CODE', true);
}
// Acquisition attribution and funnel events. Defaults to OFF, unlike the other
// product flags here, because the cost of the two mistakes is not symmetric: a
// hosted instance that forgets to switch it on shows an empty growth page and is
// noticed the same day, while a self-hosted instance that gets it silently
// switched on accumulates rows nobody asked for. Nothing is ever sent off the
// instance either way, so this is about cost, not disclosure.
export function isProductAnalyticsEnabled() {
return readBooleanEnv('OPENFRAME_ENABLE_ANALYTICS', false);
}
function parseBigIntEnv(name: string, defaultValue: bigint, minValue?: bigint): bigint {
const raw = process.env[name]?.trim();
if (!raw) return defaultValue;
+28 -1
View File
@@ -90,8 +90,11 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
'verify-email': { windowMs: 15 * 60 * 1000, maxRequests: 20 }, // 20 per 15 min (clicked link)
'resend-verification': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
// Onboarding — one-time action, very strict
// Onboarding — one-time action, very strict. Both are keyed by user id, not IP:
// an office behind one address must not be able to lock its colleagues out of
// finishing onboarding.
'onboarding-complete': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
'onboarding-source': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
// Member management
'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
@@ -105,6 +108,16 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
// Mutations (update/delete) — moderate
mutate: { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute
// Analytics beacon — anonymous and public, so bound it per IP
'analytics-beacon': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
// Anonymous visitor events recorded server-side from the landing pages. Bounds
// a flood that would otherwise write two rows per request forever, and is
// deliberately generous: these are the denominator of every rate on the
// scoreboard, so a limit that bites real traffic costs more than the flood it
// stops. Only applied when the client IP is real — see isClientIpTrustworthy.
'analytics-visitor': { windowMs: 60 * 60 * 1000, maxRequests: 240 }, // 240 per hour
// General reads — generous
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
};
@@ -261,6 +274,20 @@ export function getClientIpFromHeaders(headers: Headers): string {
return '127.0.0.1';
}
/**
* Whether {@link getClientIp} resolves to the caller rather than to 127.0.0.1.
*
* Without TRUSTED_PROXY_MODE every request shares one bucket. That is a usable
* global brake on an endpoint nobody hits in a loop, and useless on a landing
* page: the bucket would empty on real traffic long before it emptied on an
* attacker, and the counting this whole subsystem exists for would stop. Callers
* that only make sense per-client check this first.
*/
export function isClientIpTrustworthy(): boolean {
const mode = process.env.TRUSTED_PROXY_MODE?.trim().toLowerCase();
return mode === 'cloudflare' || mode === 'nginx';
}
/**
* Create rate limit headers for response
*/
@@ -0,0 +1,87 @@
-- CreateEnum
CREATE TYPE "AcquisitionChannel" AS ENUM ('DIRECT', 'GITHUB', 'YOUTUBE', 'GOOGLE', 'REVIEW_LINK', 'REFERRAL', 'OUTBOUND', 'COMMUNITY', 'OTHER');
-- CreateEnum
CREATE TYPE "AnalyticsEventName" AS ENUM ('LANDING_VIEW', 'CTA_CLICKED', 'SIGNUP_STARTED', 'SIGNUP_COMPLETED', 'EMAIL_VERIFIED', 'TRIAL_STARTED', 'WORKSPACE_CREATED', 'PROJECT_CREATED', 'VIDEO_ADDED', 'SHARE_LINK_CREATED', 'FIRST_GUEST_COMMENT', 'APPROVAL_COMPLETED', 'CHECKOUT_STARTED', 'SUBSCRIPTION_STARTED', 'SUBSCRIPTION_CANCELED', 'SUBSCRIPTION_REACTIVATED');
-- CreateTable
CREATE TABLE "acquisition_touches" (
"id" TEXT NOT NULL,
"anonymous_id" TEXT NOT NULL,
"channel" "AcquisitionChannel" NOT NULL,
"utm_source" TEXT,
"utm_medium" TEXT,
"utm_campaign" TEXT,
"referrer_host" TEXT,
"landing_path" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "acquisition_touches_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_acquisitions" (
"user_id" TEXT NOT NULL,
"anonymous_id" TEXT,
"channel" "AcquisitionChannel" NOT NULL DEFAULT 'DIRECT',
"utm_source" TEXT,
"utm_medium" TEXT,
"utm_campaign" TEXT,
"referrer_host" TEXT,
"landing_path" TEXT,
"self_reported" "AcquisitionChannel",
"self_reported_note" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_acquisitions_pkey" PRIMARY KEY ("user_id")
);
-- CreateTable
CREATE TABLE "analytics_events" (
"id" TEXT NOT NULL,
"name" "AnalyticsEventName" NOT NULL,
"user_id" TEXT,
"anonymous_id" TEXT,
"channel" "AcquisitionChannel",
"dedupe_key" TEXT NOT NULL,
"occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "analytics_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "acquisition_touches_anonymous_id_key" ON "acquisition_touches"("anonymous_id");
-- CreateIndex
CREATE INDEX "acquisition_touches_channel_created_at_idx" ON "acquisition_touches"("channel", "created_at");
-- CreateIndex
CREATE INDEX "acquisition_touches_created_at_idx" ON "acquisition_touches"("created_at");
-- CreateIndex
CREATE INDEX "user_acquisitions_channel_created_at_idx" ON "user_acquisitions"("channel", "created_at");
-- CreateIndex
CREATE INDEX "user_acquisitions_anonymous_id_idx" ON "user_acquisitions"("anonymous_id");
-- CreateIndex
CREATE UNIQUE INDEX "analytics_events_dedupe_key_key" ON "analytics_events"("dedupe_key");
-- CreateIndex
CREATE INDEX "analytics_events_name_occurred_at_idx" ON "analytics_events"("name", "occurred_at");
-- CreateIndex
CREATE INDEX "analytics_events_user_id_occurred_at_idx" ON "analytics_events"("user_id", "occurred_at");
-- CreateIndex
CREATE INDEX "analytics_events_channel_name_occurred_at_idx" ON "analytics_events"("channel", "name", "occurred_at");
-- CreateIndex
CREATE INDEX "analytics_events_anonymous_id_idx" ON "analytics_events"("anonymous_id");
-- AddForeignKey
ALTER TABLE "user_acquisitions" ADD CONSTRAINT "user_acquisitions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "analytics_events" ADD CONSTRAINT "analytics_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+115
View File
@@ -50,6 +50,8 @@ model User {
canceledApprovalRequests ApprovalRequest[] @relation("ApprovalRequestsCanceledBy")
approvalDecisions ApprovalDecision[]
sentInvitations Invitation[] @relation("InvitationsSentBy")
acquisition UserAcquisition?
analyticsEvents AnalyticsEvent[]
@@map("users")
}
@@ -738,6 +740,119 @@ model VideoUploadSession {
@@map("video_upload_sessions")
}
// ============================================
// ACQUISITION + PRODUCT ANALYTICS
// ============================================
//
// Everything here is first party. Rows are written to this deployment's own
// database and read back by this deployment's own admin panel; no part of the
// codebase ships them anywhere else. The whole subsystem is off unless
// OPENFRAME_ENABLE_ANALYTICS is set, so a self-hosted instance carries the
// tables empty and pays nothing for them.
//
// Referrers are reduced to a host and landing pages to a path, both without
// query strings, so a shared link with a name or token in it cannot leak in
// here. The one free-text column is user_acquisitions.self_reported_note, which
// holds up to 200 characters the account typed into the onboarding question.
enum AcquisitionChannel {
DIRECT
GITHUB
YOUTUBE
GOOGLE
REVIEW_LINK
REFERRAL
OUTBOUND
COMMUNITY
OTHER
}
enum AnalyticsEventName {
LANDING_VIEW
CTA_CLICKED
SIGNUP_STARTED
SIGNUP_COMPLETED
EMAIL_VERIFIED
TRIAL_STARTED
WORKSPACE_CREATED
PROJECT_CREATED
VIDEO_ADDED
SHARE_LINK_CREATED
FIRST_GUEST_COMMENT
APPROVAL_COMPLETED
CHECKOUT_STARTED
SUBSCRIPTION_STARTED
SUBSCRIPTION_CANCELED
SUBSCRIPTION_REACTIVATED
}
// First touch for a visitor who does not have an account yet. Written once per
// anonymous id and never updated: the whole point is what brought them here the
// first time, so a later visit carrying different UTM tags must not overwrite it.
model AcquisitionTouch {
id String @id @default(cuid())
anonymousId String @unique @map("anonymous_id")
channel AcquisitionChannel
utmSource String? @map("utm_source")
utmMedium String? @map("utm_medium")
utmCampaign String? @map("utm_campaign")
referrerHost String? @map("referrer_host")
landingPath String @map("landing_path")
createdAt DateTime @default(now()) @map("created_at")
@@index([channel, createdAt])
@@index([createdAt])
@@map("acquisition_touches")
}
// The first touch copied onto the account at signup, plus the answer to the
// onboarding question. Kept beside User rather than on it so the acquisition
// columns stay out of every session and billing query.
model UserAcquisition {
userId String @id @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
anonymousId String? @map("anonymous_id")
channel AcquisitionChannel @default(DIRECT)
utmSource String? @map("utm_source")
utmMedium String? @map("utm_medium")
utmCampaign String? @map("utm_campaign")
referrerHost String? @map("referrer_host")
landingPath String? @map("landing_path")
selfReported AcquisitionChannel? @map("self_reported")
selfReportedNote String? @map("self_reported_note")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([channel, createdAt])
@@index([anonymousId])
@@map("user_acquisitions")
}
// One row per funnel step. `dedupeKey` is what makes "recorded exactly once"
// a property of the schema instead of a property of every call site: a replayed
// webhook, a double-submitted form or a refreshed page all collide on the unique
// index and the second write is dropped.
//
// The user relation is SetNull rather than Cascade on purpose. A deleted account
// still happened, and dropping its rows would silently rewrite past weeks of the
// funnel.
model AnalyticsEvent {
id String @id @default(cuid())
name AnalyticsEventName
userId String? @map("user_id")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
anonymousId String? @map("anonymous_id")
channel AcquisitionChannel?
dedupeKey String @unique @map("dedupe_key")
occurredAt DateTime @default(now()) @map("occurred_at")
@@index([name, occurredAt])
@@index([userId, occurredAt])
@@index([channel, name, occurredAt])
@@index([anonymousId])
@@map("analytics_events")
}
// Rate limiting table (created as UNLOGGED via raw SQL migration)
// Defined here so `prisma db push` doesn't drop it
model RateLimit {
+80 -3
View File
@@ -1,9 +1,86 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { buildContentSecurityPolicy } from '@/lib/content-security-policy';
import {
classifyChannel,
extractReferrerHost,
sanitizeLandingPath,
sanitizeTag,
} from '@/lib/analytics/channel';
import {
ANONYMOUS_ID_COOKIE,
ANONYMOUS_ID_MAX_AGE_SECONDS,
FIRST_TOUCH_COOKIE,
generateAnonymousId,
readAnonymousIdCookie,
signAnonymousId,
signFirstTouch,
} from '@/lib/analytics/cookies';
import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
import { getPublicOrigin } from '@/lib/request-origin';
export function proxy() {
const response = NextResponse.next();
// Runs on the edge, so nothing here touches the database. It only decides who a
// visitor is and what brought them, then hands both downstream as signed
// cookies. The rows are written by the pages, which run in Node.
async function applyAcquisitionCookies(
request: NextRequest,
response: NextResponse
): Promise<void> {
if (!isProductAnalyticsEnabled()) return;
if (!isCountableDocumentRequest(request.headers)) return;
if (isLikelyBot(request.headers.get('user-agent'))) return;
const cookieOptions = {
httpOnly: true,
sameSite: 'lax' as const,
// Not `request.nextUrl.protocol`. Behind a TLS-terminating reverse proxy,
// which is the deployment shape the README documents, that is the
// container-internal `http://localhost:3000` and the flag would silently
// come off in exactly the setup that needs it.
secure: getPublicOrigin(request).startsWith('https:'),
path: '/',
maxAge: ANONYMOUS_ID_MAX_AGE_SECONDS,
};
const existingId = await readAnonymousIdCookie(request.cookies.get(ANONYMOUS_ID_COOKIE)?.value);
if (!existingId) {
const signedId = await signAnonymousId(generateAnonymousId());
if (!signedId) return;
// Set on the request as well as the response: without this the page rendering
// *this* request cannot see the id, and the first landing view of every new
// visitor, the one carrying the campaign tags, goes unrecorded.
request.cookies.set(ANONYMOUS_ID_COOKIE, signedId);
response.cookies.set(ANONYMOUS_ID_COOKIE, signedId, cookieOptions);
}
if (request.cookies.get(FIRST_TOUCH_COOKIE)) return;
const params = request.nextUrl.searchParams;
const referrerHost = extractReferrerHost(
request.headers.get('referer'),
request.nextUrl.hostname
);
const utmSource = sanitizeTag(params.get('utm_source'));
const utmMedium = sanitizeTag(params.get('utm_medium'));
const firstTouch = await signFirstTouch({
channel: classifyChannel({ utmSource, utmMedium, referrerHost }),
utmSource,
utmMedium,
utmCampaign: sanitizeTag(params.get('utm_campaign')),
referrerHost,
landingPath: sanitizeLandingPath(request.nextUrl.pathname),
});
if (!firstTouch) return;
request.cookies.set(FIRST_TOUCH_COOKIE, firstTouch);
response.cookies.set(FIRST_TOUCH_COOKIE, firstTouch, cookieOptions);
}
export async function proxy(request: NextRequest) {
const response = NextResponse.next({ request });
response.headers.set('Content-Security-Policy', buildContentSecurityPolicy());
await applyAcquisitionCookies(request, response);
return response;
}
+417
View File
@@ -0,0 +1,417 @@
// The property the whole acquisition system rests on: every funnel event is
// recorded exactly once, against the right account, and nothing at all is
// recorded when the feature flag is off.
//
// The dedupe key is a UNIQUE column, so these tests are checking that each call
// site derives the right key. A key that varies when it should not shows up here
// as a duplicated row, which is the failure that would quietly inflate the
// scoreboard.
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { db } from '@/lib/db';
import { POST as beacon } from '@/app/api/events/route';
import { POST as register } from '@/app/api/auth/register/route';
import { recordSubscriptionTransition } from '@/lib/analytics/billing-events';
import { recordSignupCompleted } from '@/lib/analytics/signup';
import { signAnonymousId, signFirstTouch, type FirstTouch } from '@/lib/analytics/cookies';
import { apiRequest, callRoute } from '../helpers/request';
import { signedOut } from '../helpers/session';
import { createUser } from '../factories';
const ANON_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
const INVITE_CODE = 'test-invite';
const ORIGIN = 'http://localhost:3000';
const SECRET = 'analytics-test-secret';
const BROWSER_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36';
const GITHUB_TOUCH: FirstTouch = {
channel: 'GITHUB',
utmSource: 'github',
utmMedium: 'readme',
utmCampaign: null,
referrerHost: 'github.com',
landingPath: '/',
};
/** What the proxy would have set. Signed, because nothing downstream trusts anything else. */
async function visitorCookies(anonymousId = ANON_ID, touch: FirstTouch = GITHUB_TOUCH) {
return {
of_aid: (await signAnonymousId(anonymousId)) ?? '',
of_ft: (await signFirstTouch(touch)) ?? '',
};
}
async function beaconRequest(options?: {
name?: string;
origin?: string | null;
userAgent?: string | null;
cookies?: Record<string, string>;
}) {
const headers: Record<string, string> = {};
const origin = options?.origin === undefined ? ORIGIN : options.origin;
if (origin) headers.origin = origin;
const userAgent = options?.userAgent === undefined ? BROWSER_UA : options.userAgent;
if (userAgent) headers['user-agent'] = userAgent;
return apiRequest('/api/events', {
body: { name: options?.name ?? 'cta_clicked' },
headers,
cookies: options?.cookies ?? (await visitorCookies()),
});
}
async function eventNames(): Promise<string[]> {
const rows = await db.analyticsEvent.findMany({ orderBy: { dedupeKey: 'asc' } });
return rows.map((row) => row.name);
}
beforeEach(() => {
signedOut();
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
vi.stubEnv('NEXTAUTH_SECRET', SECRET);
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('POST /api/events', () => {
it('records a CTA click and the first touch behind it', async () => {
const response = await callRoute(beacon, await beaconRequest());
expect(response.status).toBe(204);
const events = await db.analyticsEvent.findMany();
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
name: 'CTA_CLICKED',
anonymousId: ANON_ID,
channel: 'GITHUB',
userId: null,
});
const touches = await db.acquisitionTouch.findMany();
expect(touches).toHaveLength(1);
expect(touches[0]).toMatchObject({
anonymousId: ANON_ID,
channel: 'GITHUB',
utmSource: 'github',
referrerHost: 'github.com',
});
});
it('records one event however many times the same visitor clicks', async () => {
await callRoute(beacon, await beaconRequest());
await callRoute(beacon, await beaconRequest());
await callRoute(beacon, await beaconRequest());
expect(await db.analyticsEvent.count()).toBe(1);
});
it('counts two different visitors separately', async () => {
await callRoute(beacon, await beaconRequest());
await callRoute(
beacon,
await beaconRequest({ cookies: await visitorCookies('f0e1d2c3b4a596877869504132231415') })
);
expect(await db.analyticsEvent.count()).toBe(2);
});
it('never keeps the first touch of a visitor who came back through another link', async () => {
await callRoute(beacon, await beaconRequest());
await callRoute(
beacon,
await beaconRequest({
cookies: await visitorCookies(ANON_ID, {
...GITHUB_TOUCH,
channel: 'GOOGLE',
utmSource: null,
}),
})
);
const touches = await db.acquisitionTouch.findMany();
expect(touches).toHaveLength(1);
expect(touches[0]?.channel).toBe('GITHUB');
});
it('refuses to record an event name the beacon does not own', async () => {
// Without this the endpoint would let any anonymous caller write a payment
// into the funnel.
for (const name of ['subscription_started', 'signup_completed', 'SUBSCRIPTION_STARTED', '']) {
const response = await callRoute(beacon, await beaconRequest({ name }));
expect(response.status, name).toBe(204);
}
expect(await db.analyticsEvent.count()).toBe(0);
});
it('ignores a cross-origin caller', async () => {
const response = await callRoute(
beacon,
await beaconRequest({ origin: 'https://evil.example' })
);
expect(response.status).toBe(204);
expect(await db.analyticsEvent.count()).toBe(0);
});
it('ignores a caller with no anonymous id cookie', async () => {
await callRoute(beacon, await beaconRequest({ cookies: {} }));
expect(await db.analyticsEvent.count()).toBe(0);
expect(await db.acquisitionTouch.count()).toBe(0);
});
it('ignores a hand-written cookie, whatever channel it claims', async () => {
// httpOnly stops JavaScript, not curl. Without the signature this is a
// visitor of the caller's choosing, filed under a channel of their choosing,
// and every row on the scoreboard is theirs to write.
await callRoute(
beacon,
await beaconRequest({
cookies: {
of_aid: 'deadbeefdeadbeefdeadbeefdeadbeef',
of_ft: encodeURIComponent(JSON.stringify({ c: 'GITHUB', p: '/' })),
},
})
);
expect(await db.analyticsEvent.count()).toBe(0);
expect(await db.acquisitionTouch.count()).toBe(0);
});
it('ignores a cookie signed by another deployment', async () => {
const cookies = await visitorCookies();
vi.stubEnv('NEXTAUTH_SECRET', 'some-other-secret');
await callRoute(beacon, await beaconRequest({ cookies }));
expect(await db.analyticsEvent.count()).toBe(0);
});
it('ignores a script that sends no user agent', async () => {
await callRoute(beacon, await beaconRequest({ userAgent: null }));
expect(await db.analyticsEvent.count()).toBe(0);
});
it('writes nothing at all when the flag is off', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
const response = await callRoute(beacon, await beaconRequest());
expect(response.status).toBe(204);
expect(await db.analyticsEvent.count()).toBe(0);
expect(await db.acquisitionTouch.count()).toBe(0);
});
});
describe('signup attribution', () => {
async function registerWithCookies(email: string) {
return callRoute(
register,
apiRequest('/api/auth/register', {
body: {
name: 'New User',
email,
password: 'correct horse battery',
inviteCode: INVITE_CODE,
},
headers: { 'user-agent': BROWSER_UA },
cookies: await visitorCookies(),
})
);
}
it('copies the first touch onto the account and records the signup once', async () => {
const response = await registerWithCookies('[email protected]');
expect(response.status).toBe(201);
const user = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
const acquisition = await db.userAcquisition.findUniqueOrThrow({
where: { userId: user.id },
});
expect(acquisition).toMatchObject({
channel: 'GITHUB',
utmSource: 'github',
utmMedium: 'readme',
referrerHost: 'github.com',
anonymousId: ANON_ID,
});
const signups = await db.analyticsEvent.findMany({ where: { name: 'SIGNUP_COMPLETED' } });
expect(signups).toHaveLength(1);
expect(signups[0]?.userId).toBe(user.id);
});
it('claims the events the visitor produced before they had an account', async () => {
await callRoute(beacon, await beaconRequest());
await registerWithCookies('[email protected]');
const user = await db.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
const click = await db.analyticsEvent.findFirstOrThrow({ where: { name: 'CTA_CLICKED' } });
// Without the backfill the click and the signup are two unrelated rows and
// no query can tell you which channel converted.
expect(click.userId).toBe(user.id);
});
it('records one signup even if the helper runs twice', async () => {
const user = await createUser();
await recordSignupCompleted({
userId: user.id,
visitor: { anonymousId: ANON_ID, firstTouch: GITHUB_TOUCH, clientIp: null },
});
await recordSignupCompleted({
userId: user.id,
visitor: { anonymousId: ANON_ID, firstTouch: GITHUB_TOUCH, clientIp: null },
});
expect(await db.analyticsEvent.count({ where: { name: 'SIGNUP_COMPLETED' } })).toBe(1);
expect(await db.userAcquisition.count({ where: { userId: user.id } })).toBe(1);
});
it('records no acquisition row when the flag is off', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
const response = await registerWithCookies('[email protected]');
expect(response.status).toBe(201);
expect(await db.userAcquisition.count()).toBe(0);
expect(await db.analyticsEvent.count()).toBe(0);
});
});
describe('subscription transitions', () => {
const SUB = 'sub_test_1';
const periodEnd = new Date('2026-09-01T00:00:00.000Z');
async function transition(params: {
userId: string;
beforeStatus: 'FREE' | 'TRIALING' | 'ACTIVE' | 'CANCELED';
afterStatus: 'FREE' | 'TRIALING' | 'ACTIVE' | 'CANCELED';
beforeCancelAtPeriodEnd?: boolean;
afterCancelAtPeriodEnd?: boolean;
hadTrial?: boolean;
trialEndsAt?: Date | null;
}) {
await recordSubscriptionTransition({
userId: params.userId,
subscriptionId: SUB,
before: {
status: params.beforeStatus,
cancelAtPeriodEnd: params.beforeCancelAtPeriodEnd ?? false,
hadTrial: params.hadTrial ?? false,
},
after: {
status: params.afterStatus,
cancelAtPeriodEnd: params.afterCancelAtPeriodEnd ?? false,
trialEndsAt: params.trialEndsAt ?? null,
currentPeriodEnd: periodEnd,
},
});
}
it('records the trial once and the conversion to paid once', async () => {
const user = await createUser();
await transition({
userId: user.id,
beforeStatus: 'FREE',
afterStatus: 'TRIALING',
trialEndsAt: new Date('2026-08-15T00:00:00.000Z'),
});
// The same webhook arriving again, which Stripe does routinely.
await transition({
userId: user.id,
beforeStatus: 'FREE',
afterStatus: 'TRIALING',
trialEndsAt: new Date('2026-08-15T00:00:00.000Z'),
});
await transition({
userId: user.id,
beforeStatus: 'TRIALING',
afterStatus: 'ACTIVE',
hadTrial: true,
});
expect(await eventNames()).toEqual(['SUBSCRIPTION_STARTED', 'TRIAL_STARTED']);
});
it('does not record a second trial for an account that already had one', async () => {
const user = await createUser();
await transition({
userId: user.id,
beforeStatus: 'CANCELED',
afterStatus: 'TRIALING',
hadTrial: true,
trialEndsAt: new Date('2026-08-15T00:00:00.000Z'),
});
expect(await eventNames()).toEqual([]);
});
it('counts one cancellation for the flag and the status that follow each other', async () => {
const user = await createUser();
// The customer cancels in the portal: cancel_at_period_end flips on.
await transition({
userId: user.id,
beforeStatus: 'ACTIVE',
afterStatus: 'ACTIVE',
afterCancelAtPeriodEnd: true,
});
// The term ends weeks later and Stripe marks the subscription canceled.
await transition({
userId: user.id,
beforeStatus: 'ACTIVE',
afterStatus: 'CANCELED',
beforeCancelAtPeriodEnd: true,
});
expect(await db.analyticsEvent.count({ where: { name: 'SUBSCRIPTION_CANCELED' } })).toBe(1);
});
it('records a reactivation when the customer changes their mind', async () => {
const user = await createUser();
await transition({
userId: user.id,
beforeStatus: 'ACTIVE',
afterStatus: 'ACTIVE',
afterCancelAtPeriodEnd: true,
});
await transition({
userId: user.id,
beforeStatus: 'ACTIVE',
afterStatus: 'ACTIVE',
beforeCancelAtPeriodEnd: true,
afterCancelAtPeriodEnd: false,
});
expect(await eventNames()).toEqual(['SUBSCRIPTION_CANCELED', 'SUBSCRIPTION_REACTIVATED']);
});
it('records nothing when nothing changed', async () => {
const user = await createUser();
await transition({ userId: user.id, beforeStatus: 'ACTIVE', afterStatus: 'ACTIVE' });
expect(await eventNames()).toEqual([]);
});
it('writes nothing when the flag is off', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
const user = await createUser();
await transition({ userId: user.id, beforeStatus: 'FREE', afterStatus: 'ACTIVE' });
expect(await db.analyticsEvent.count()).toBe(0);
});
});
+155
View File
@@ -0,0 +1,155 @@
// Exercises the scoreboard queries against a real database.
//
// These are raw SQL: a date_trunc grouping, a COALESCE across two tables and a
// filtered left join. None of that is checked by the type system, so a seeded
// week with known counts is the only thing standing between a renamed column and
// a growth page that renders zeros forever.
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import type { AcquisitionChannel, AnalyticsEventName } from '@prisma/client';
import { db } from '@/lib/db';
import { AT_RISK_SILENT_DAYS, getScoreboard } from '@/lib/analytics/scoreboard';
import { createUser } from '../factories';
function daysAgo(days: number): Date {
const date = new Date();
date.setUTCDate(date.getUTCDate() - days);
return date;
}
let sequence = 0;
async function seedEvent(params: {
name: AnalyticsEventName;
occurredAt: Date;
userId?: string;
anonymousId?: string;
channel?: AcquisitionChannel;
}) {
sequence += 1;
await db.analyticsEvent.create({
data: {
name: params.name,
dedupeKey: `${params.name}:seed-${sequence}`,
occurredAt: params.occurredAt,
userId: params.userId ?? null,
anonymousId: params.anonymousId ?? null,
channel: params.channel ?? null,
},
});
}
beforeEach(() => {
sequence = 0;
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('getScoreboard', () => {
it('returns an empty week for every week in the window when nothing happened', async () => {
const scoreboard = await getScoreboard({ weeks: 4 });
expect(scoreboard.weeks).toHaveLength(4);
expect(scoreboard.weeks.every((week) => week.visitors === 0)).toBe(true);
expect(scoreboard.channels).toEqual([]);
expect(scoreboard.paidAccounts).toEqual([]);
});
it('counts a returning visitor once per week, not once per visit', async () => {
// Landing views are deduped per visitor per day, so the same person on three
// days is three rows. Weekly visitors is a distinct count over the id.
for (const days of [1, 2, 3]) {
await seedEvent({
name: 'LANDING_VIEW',
occurredAt: daysAgo(days),
anonymousId: 'visitor-one',
channel: 'GITHUB',
});
}
await seedEvent({
name: 'LANDING_VIEW',
occurredAt: daysAgo(1),
anonymousId: 'visitor-two',
channel: 'GOOGLE',
});
const scoreboard = await getScoreboard({ weeks: 2 });
const total = scoreboard.weeks.reduce((sum, week) => sum + week.visitors, 0);
expect(total).toBe(2);
});
it('reads a signed-up visitor through the channel on their account', async () => {
const user = await createUser();
await db.userAcquisition.create({
data: { userId: user.id, channel: 'YOUTUBE', anonymousId: 'visitor-three' },
});
// The visitor event carries GITHUB from the cookie, but the account says
// YouTube. The account wins, so correcting a channel corrects its history.
await seedEvent({
name: 'LANDING_VIEW',
occurredAt: daysAgo(2),
anonymousId: 'visitor-three',
channel: 'GITHUB',
userId: user.id,
});
await seedEvent({
name: 'SIGNUP_COMPLETED',
occurredAt: daysAgo(2),
userId: user.id,
anonymousId: 'visitor-three',
});
const scoreboard = await getScoreboard({ weeks: 2 });
const youtube = scoreboard.channels.find((row) => row.channel === 'YOUTUBE');
expect(youtube).toMatchObject({ visitors: 1, signups: 1 });
expect(scoreboard.channels.find((row) => row.channel === 'GITHUB')).toBeUndefined();
});
it('carries subscriptions started before the window into the running total', async () => {
await seedEvent({ name: 'SUBSCRIPTION_STARTED', occurredAt: daysAgo(120) });
await seedEvent({ name: 'SUBSCRIPTION_STARTED', occurredAt: daysAgo(3) });
await seedEvent({ name: 'SUBSCRIPTION_CANCELED', occurredAt: daysAgo(3) });
const scoreboard = await getScoreboard({ weeks: 2 });
const last = scoreboard.weeks[scoreboard.weeks.length - 1];
// One from before the window, plus one started and one canceled inside it.
expect(last?.activePaid).toBe(1);
expect(last?.newPaid).toBe(1);
expect(last?.canceled).toBe(1);
});
it('flags a paid account that has produced nothing recently', async () => {
const busy = await createUser({ subscriptionStatus: 'ACTIVE' });
const silent = await createUser({ subscriptionStatus: 'ACTIVE' });
const trialing = await createUser({ subscriptionStatus: 'TRIALING' });
await createUser({ subscriptionStatus: 'FREE' });
await seedEvent({ name: 'VIDEO_ADDED', occurredAt: daysAgo(2), userId: busy.id });
await seedEvent({ name: 'SHARE_LINK_CREATED', occurredAt: daysAgo(20), userId: busy.id });
await seedEvent({
name: 'VIDEO_ADDED',
occurredAt: daysAgo(AT_RISK_SILENT_DAYS + 5),
userId: silent.id,
});
// A signup is not a value event, so it must not clear the risk flag.
await seedEvent({ name: 'SIGNUP_COMPLETED', occurredAt: daysAgo(1), userId: trialing.id });
const scoreboard = await getScoreboard({ weeks: 4 });
const ids = scoreboard.paidAccounts.map((row) => row.userId).sort();
const atRisk = scoreboard.atRisk.map((row) => row.userId).sort();
expect(ids).toEqual([busy.id, silent.id, trialing.id].sort());
expect(atRisk).toEqual([silent.id, trialing.id].sort());
const busyRow = scoreboard.paidAccounts.find((row) => row.userId === busy.id);
expect(busyRow?.valueEvents7).toBe(1);
expect(busyRow?.valueEvents30).toBe(2);
});
});
+23 -1
View File
@@ -44,6 +44,7 @@ import {
} from '../factories';
import * as adminFeedbackRoute from '@/app/api/admin/feedback/[feedbackId]/route';
import * as adminGrowthRoute from '@/app/api/admin/growth/route';
import * as adminRefreshR2Route from '@/app/api/admin/stats/refresh-r2/route';
import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/route';
import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route';
@@ -54,6 +55,7 @@ import * as commentRoute from '@/app/api/comments/[commentId]/route';
import * as feedbackRoute from '@/app/api/feedback/route';
import * as feedbackUploadRoute from '@/app/api/feedback/upload/route';
import * as onboardingCompleteRoute from '@/app/api/onboarding/complete/route';
import * as onboardingSourceRoute from '@/app/api/onboarding/source/route';
import * as approvalCandidatesRoute from '@/app/api/projects/[projectId]/approval-candidates/route';
import * as projectDownloadRoute from '@/app/api/projects/[projectId]/download/route';
import * as projectInvitationRoute from '@/app/api/projects/[projectId]/members/invitations/[invitationId]/route';
@@ -143,7 +145,7 @@ vi.mock('@/lib/r2', async (importOriginal) => {
// The count guard
// ---------------------------------------------------------------------------
// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES.
const EXPECTED_ROUTE_MODULE_COUNT = 60;
const EXPECTED_ROUTE_MODULE_COUNT = 63;
/**
* Routes that are public by design, and why. Everything else must reject an
@@ -176,6 +178,15 @@ const PUBLIC_ROUTES: ReadonlyMap<string, string> = new Map([
// so it cannot be used to enumerate accounts.
'resend of the verification email, for users who cannot sign in yet',
],
[
'events/route.ts',
// The CTA-click beacon. Its whole job is to hear from visitors who have no
// account yet, so a session cannot be the guard. It is bounded three ways
// instead: same-origin only, IP rate limited, and it accepts exactly one
// event name, so nothing a caller sends can forge a signup or a payment.
// Covered in tests/api/analytics-events.test.ts.
'anonymous CTA beacon, restricted to one event name and to same-origin callers',
],
[
'stripe/webhook/route.ts',
// Called by Stripe, not by a browser. Authenticated by the HMAC signature
@@ -350,6 +361,11 @@ const ROUTE_CASES: readonly RouteCase[] = [
url: (f) => `/api/admin/feedback/${f.feedbackId}`,
params: (f) => ({ feedbackId: f.feedbackId }),
},
{
file: 'admin/growth/route.ts',
module: adminGrowthRoute,
url: () => '/api/admin/growth',
},
{
file: 'admin/stats/refresh-r2/route.ts',
module: adminRefreshR2Route,
@@ -405,6 +421,12 @@ const ROUTE_CASES: readonly RouteCase[] = [
module: onboardingCompleteRoute,
url: () => '/api/onboarding/complete',
},
{
file: 'onboarding/source/route.ts',
module: onboardingSourceRoute,
url: () => '/api/onboarding/source',
body: { source: 'GITHUB' },
},
{
file: 'projects/[projectId]/approval-candidates/route.ts',
module: approvalCandidatesRoute,
+1
View File
@@ -66,6 +66,7 @@ const REVIEWED_MIGRATIONS = [
'20260613120000_add_r2_video_asset_provider',
'20260614160000_add_project_allow_downloads',
'20260627140000_add_video_upload_multipart_id',
'20260801120000_add_acquisition_analytics',
];
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
+144
View File
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest';
import {
classifyChannel,
extractReferrerHost,
normalizeHost,
sanitizeLandingPath,
sanitizeTag,
} from '@/lib/analytics/channel';
// Every expected value below is written by hand. Deriving them from the lookup
// tables in the module would mean deleting an entry from a table also deletes
// its own test case.
describe('sanitizeTag', () => {
it('lowercases and trims', () => {
expect(sanitizeTag(' GitHub ')).toBe('github');
});
it('rejects a tag carrying markup or control characters', () => {
expect(sanitizeTag('<script>')).toBeNull();
expect(sanitizeTag('news\nletter')).toBeNull();
});
it('caps the length at 64 characters', () => {
expect(sanitizeTag('a'.repeat(200))).toHaveLength(64);
});
it('treats an empty or non-string value as absent', () => {
expect(sanitizeTag(' ')).toBeNull();
expect(sanitizeTag(null)).toBeNull();
expect(sanitizeTag(undefined)).toBeNull();
});
});
describe('normalizeHost', () => {
it('drops the www prefix and the port', () => {
expect(normalizeHost('WWW.GitHub.com:443')).toBe('github.com');
});
it('rejects a value that is not a host', () => {
expect(normalizeHost('not a host')).toBeNull();
expect(normalizeHost('https://github.com')).toBeNull();
});
});
describe('extractReferrerHost', () => {
it('returns the host of a full referrer URL', () => {
expect(extractReferrerHost('https://news.ycombinator.com/item?id=1')).toBe(
'news.ycombinator.com'
);
});
it('drops the path and query, so a share token cannot be stored', () => {
expect(extractReferrerHost('https://example.com/share/[email protected]')).toBe(
'example.com'
);
});
it('ignores our own host, because that is a click inside the site', () => {
expect(extractReferrerHost('https://open-frame.net/pricing', 'open-frame.net')).toBeNull();
expect(extractReferrerHost('https://www.open-frame.net/pricing', 'open-frame.net')).toBeNull();
});
it('returns null for a missing or unparseable referrer', () => {
expect(extractReferrerHost(null)).toBeNull();
expect(extractReferrerHost('android-app://com.example')).toBeNull();
});
});
describe('sanitizeLandingPath', () => {
it('keeps the path and drops the query string', () => {
expect(sanitizeLandingPath('/vs/frameio')).toBe('/vs/frameio');
});
it('falls back to / for anything that is not a path', () => {
expect(sanitizeLandingPath('https://open-frame.net/x')).toBe('/');
expect(sanitizeLandingPath(null)).toBe('/');
});
it('keeps what a real route can carry', () => {
expect(sanitizeLandingPath('/vs/frame.io')).toBe('/vs/frame.io');
expect(sanitizeLandingPath('/watch/cm4x-01_a')).toBe('/watch/cm4x-01_a');
expect(sanitizeLandingPath('/blog/%C3%BCr%C3%BCn')).toBe('/blog/%C3%BCr%C3%BCn');
});
it('drops a path that could only have come from a hand-written cookie', () => {
// The proxy feeds this a real pathname. The cookie reader feeds it whatever
// the cookie said, and that value ends up in a database column.
expect(sanitizeLandingPath('/<script>alert(1)</script>')).toBe('/');
expect(sanitizeLandingPath('/ok\nX-Injected: 1')).toBe('/');
expect(sanitizeLandingPath('/a b')).toBe('/');
});
});
describe('classifyChannel', () => {
it('is DIRECT with no tags and no referrer', () => {
expect(classifyChannel({})).toBe('DIRECT');
});
it('reads the referring host when there are no tags', () => {
expect(classifyChannel({ referrerHost: 'github.com' })).toBe('GITHUB');
expect(classifyChannel({ referrerHost: 'gist.github.com' })).toBe('GITHUB');
expect(classifyChannel({ referrerHost: 'youtu.be' })).toBe('YOUTUBE');
expect(classifyChannel({ referrerHost: 'www.producthunt.com' })).toBe('REVIEW_LINK');
expect(classifyChannel({ referrerHost: 'news.ycombinator.com' })).toBe('COMMUNITY');
});
it('treats every Google country domain as search', () => {
expect(classifyChannel({ referrerHost: 'google.com' })).toBe('GOOGLE');
expect(classifyChannel({ referrerHost: 'google.com.tr' })).toBe('GOOGLE');
expect(classifyChannel({ referrerHost: 'news.google.co.uk' })).toBe('GOOGLE');
});
it('does not mistake a lookalike domain for the real one', () => {
expect(classifyChannel({ referrerHost: 'notgithub.com' })).toBe('REFERRAL');
expect(classifyChannel({ referrerHost: 'google.com.evil.example' })).toBe('REFERRAL');
});
it('counts an unrecognised site that links to us as a referral', () => {
expect(classifyChannel({ referrerHost: 'someblog.example' })).toBe('REFERRAL');
});
it('prefers an explicit utm_source over the referring host', () => {
expect(classifyChannel({ utmSource: 'youtube', referrerHost: 'google.com' })).toBe('YOUTUBE');
});
it('reads a utm_source that was written as a domain', () => {
expect(classifyChannel({ utmSource: 'github.com' })).toBe('GITHUB');
});
it('files a tagged campaign we do not recognise as OTHER, not DIRECT', () => {
expect(classifyChannel({ utmSource: 'conference-flyer' })).toBe('OTHER');
});
it('lets the medium that names the motion win over the source that names the place', () => {
expect(classifyChannel({ utmSource: 'linkedin', utmMedium: 'outbound' })).toBe('OUTBOUND');
expect(classifyChannel({ utmSource: 'github', utmMedium: 'email' })).toBe('OUTBOUND');
expect(classifyChannel({ utmSource: 'someone', utmMedium: 'referral' })).toBe('REFERRAL');
});
it('ignores a source that fails sanitizing and falls back to the referrer', () => {
expect(classifyChannel({ utmSource: '<script>', referrerHost: 'youtube.com' })).toBe('YOUTUBE');
});
});
+223
View File
@@ -0,0 +1,223 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
decodeFirstTouch,
encodeFirstTouch,
generateAnonymousId,
isAcquisitionChannel,
isValidAnonymousId,
readAnonymousIdCookie,
readFirstTouchCookie,
signAnonymousId,
signFirstTouch,
type FirstTouch,
} from '@/lib/analytics/cookies';
import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots';
beforeEach(() => {
vi.stubEnv('NEXTAUTH_SECRET', 'cookie-test-secret');
});
afterEach(() => {
vi.unstubAllEnvs();
});
/** The cookie body a forged value would have to carry, in the wire form the reader expects. */
function body(payload: unknown): string {
return btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
const TOUCH: FirstTouch = {
channel: 'GITHUB',
utmSource: 'github',
utmMedium: 'readme',
utmCampaign: 'launch',
referrerHost: 'github.com',
landingPath: '/vs/frameio',
};
describe('first touch cookie', () => {
it('round-trips every field', () => {
expect(decodeFirstTouch(encodeFirstTouch(TOUCH))).toEqual(TOUCH);
});
it('round-trips a touch with nothing but a channel', () => {
const bare: FirstTouch = {
channel: 'DIRECT',
utmSource: null,
utmMedium: null,
utmCampaign: null,
referrerHost: null,
landingPath: '/',
};
expect(decodeFirstTouch(encodeFirstTouch(bare))).toEqual(bare);
});
it('rejects a hand-edited cookie carrying an unknown channel', () => {
expect(decodeFirstTouch(body({ c: 'INVESTOR_DEMO', p: '/' }))).toBeNull();
});
it('re-sanitizes fields rather than trusting the cookie', () => {
const decoded = decodeFirstTouch(
body({ c: 'DIRECT', p: '/x', s: '<script>alert(1)</script>', r: 'not a host' })
);
expect(decoded?.utmSource).toBeNull();
expect(decoded?.referrerHost).toBeNull();
});
it('re-sanitizes a landing path that could only have been hand-written', () => {
expect(decodeFirstTouch(body({ c: 'DIRECT', p: '/<img src=x onerror=1>' }))?.landingPath).toBe(
'/'
);
});
it('returns null for garbage and for an absent cookie', () => {
expect(decodeFirstTouch('%%%not-base64%%%')).toBeNull();
expect(decodeFirstTouch(null)).toBeNull();
expect(decodeFirstTouch(body(['DIRECT']))).toBeNull();
});
});
describe('anonymous id', () => {
it('generates an id the validator accepts', () => {
expect(isValidAnonymousId(generateAnonymousId())).toBe(true);
});
it('generates a different id each time', () => {
expect(generateAnonymousId()).not.toBe(generateAnonymousId());
});
it('rejects an id that is too short, too long or not base36', () => {
expect(isValidAnonymousId('abc')).toBe(false);
expect(isValidAnonymousId('a'.repeat(65))).toBe(false);
expect(isValidAnonymousId('ABCDEF0123456789ABCD')).toBe(false);
expect(isValidAnonymousId(undefined)).toBe(false);
});
});
// Everything above tests the unsigned inner layer. Nothing outside the module
// uses it: a cookie is only a visitor once the signature says this deployment
// issued it, which is what stops a caller from inventing one with curl.
describe('signed cookies', () => {
const TOUCH_TO_SIGN: FirstTouch = {
channel: 'YOUTUBE',
utmSource: 'yt',
utmMedium: null,
utmCampaign: null,
referrerHost: 'youtube.com',
landingPath: '/',
};
it('round-trips an id and a first touch', async () => {
const id = generateAnonymousId();
expect(await readAnonymousIdCookie(await signAnonymousId(id))).toBe(id);
expect(await readFirstTouchCookie(await signFirstTouch(TOUCH_TO_SIGN))).toEqual(TOUCH_TO_SIGN);
});
it('rejects a well-formed id that carries no signature', async () => {
expect(await readAnonymousIdCookie('a1b2c3d4e5f60718293a4b5c6d7e8f90')).toBeNull();
});
it('rejects a value whose body was edited under a valid signature', async () => {
const signed = (await signAnonymousId(generateAnonymousId())) ?? '';
const [mac] = signed.split('.');
expect(await readAnonymousIdCookie(`${mac}.a1b2c3d4e5f60718293a4b5c6d7e8f90`)).toBeNull();
});
it('rejects a first touch re-signed to name another channel', async () => {
const forgedBody = encodeFirstTouch({ ...TOUCH_TO_SIGN, channel: 'GITHUB' });
const signed = (await signFirstTouch(TOUCH_TO_SIGN)) ?? '';
const [mac] = signed.split('.');
expect(await readFirstTouchCookie(`${mac}.${forgedBody}`)).toBeNull();
});
it('rejects a cookie signed with another deployment key', async () => {
const signed = await signAnonymousId(generateAnonymousId());
vi.stubEnv('NEXTAUTH_SECRET', 'someone-elses-secret');
expect(await readAnonymousIdCookie(signed)).toBeNull();
});
it('signs nothing and accepts nothing when there is no secret', async () => {
const signed = await signAnonymousId(generateAnonymousId());
vi.stubEnv('NEXTAUTH_SECRET', undefined);
vi.stubEnv('AUTH_SECRET', undefined);
expect(await signAnonymousId(generateAnonymousId())).toBeNull();
expect(await readAnonymousIdCookie(signed)).toBeNull();
});
it('rejects the empty, the truncated and the separator-less', async () => {
expect(await readAnonymousIdCookie('')).toBeNull();
expect(await readAnonymousIdCookie(undefined)).toBeNull();
expect(await readAnonymousIdCookie('.')).toBeNull();
expect(await readAnonymousIdCookie('a'.repeat(22))).toBeNull();
expect(await readFirstTouchCookie('not-signed-at-all')).toBeNull();
});
});
describe('isAcquisitionChannel', () => {
it('accepts the nine buckets and nothing else', () => {
expect(isAcquisitionChannel('REVIEW_LINK')).toBe(true);
expect(isAcquisitionChannel('direct')).toBe(false);
expect(isAcquisitionChannel(7)).toBe(false);
});
});
describe('isLikelyBot', () => {
it('passes a real browser through', () => {
expect(
isLikelyBot(
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36'
)
).toBe(false);
});
it('catches crawlers, link previewers and scripts', () => {
expect(isLikelyBot('Googlebot/2.1 (+http://www.google.com/bot.html)')).toBe(true);
expect(isLikelyBot('facebookexternalhit/1.1')).toBe(true);
expect(isLikelyBot('curl/8.4.0')).toBe(true);
expect(isLikelyBot('python-requests/2.31.0')).toBe(true);
expect(isLikelyBot('HeadlessChrome/120.0.0.0')).toBe(true);
});
it('treats a missing user agent as a bot', () => {
expect(isLikelyBot('')).toBe(true);
expect(isLikelyBot(null)).toBe(true);
});
});
describe('isCountableDocumentRequest', () => {
it('counts a real page load', () => {
expect(isCountableDocumentRequest(new Headers({ 'sec-fetch-dest': 'document' }))).toBe(true);
});
it('does not count a prefetch of the register page', () => {
expect(
isCountableDocumentRequest(
new Headers({ 'sec-fetch-dest': 'document', 'sec-purpose': 'prefetch;prerender' })
)
).toBe(false);
expect(
isCountableDocumentRequest(
new Headers({ 'sec-fetch-dest': 'document', 'next-router-prefetch': '1' })
)
).toBe(false);
});
it('does not count an RSC navigation or a subresource', () => {
expect(
isCountableDocumentRequest(new Headers({ 'sec-fetch-dest': 'document', rsc: '1' }))
).toBe(false);
expect(isCountableDocumentRequest(new Headers({ 'sec-fetch-dest': 'image' }))).toBe(false);
});
it('falls back to the accept header when fetch metadata is missing', () => {
expect(isCountableDocumentRequest(new Headers({ accept: 'text/html,*/*' }))).toBe(true);
expect(isCountableDocumentRequest(new Headers({ accept: 'application/json' }))).toBe(false);
expect(isCountableDocumentRequest(new Headers())).toBe(false);
});
});
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { conversionRates } from '@/lib/analytics/scoreboard';
const WEEK = {
visitors: 200,
signups: 20,
firstVideo: 10,
shareLinks: 5,
externalFeedback: 1,
trials: 4,
newPaid: 1,
};
describe('conversionRates', () => {
it('divides each step by the one above it', () => {
const rates = conversionRates(WEEK);
expect(rates.visitorToSignup).toBeCloseTo(0.1);
expect(rates.signupToFirstVideo).toBeCloseTo(0.5);
expect(rates.firstVideoToShare).toBeCloseTo(0.5);
expect(rates.shareToFeedback).toBeCloseTo(0.2);
expect(rates.trialToPaid).toBeCloseTo(0.25);
});
it('returns null rather than zero when the denominator is zero', () => {
const rates = conversionRates({ ...WEEK, visitors: 0, trials: 0 });
expect(rates.visitorToSignup).toBeNull();
expect(rates.trialToPaid).toBeNull();
// "nobody arrived" and "nobody converted" are different facts, and the rest
// of the funnel still has to report normally.
expect(rates.signupToFirstVideo).toBeCloseTo(0.5);
});
it('reports a step where nobody converted as zero, not as missing', () => {
expect(conversionRates({ ...WEEK, newPaid: 0 }).trialToPaid).toBe(0);
});
});
+229
View File
@@ -0,0 +1,229 @@
// The proxy is where a visitor gets an identity, and it is the only place that
// can: it runs on the edge, before the page, on every document request.
//
// Two load-bearing details below. The id is written to the *request* as well as
// the response, because a cookie set only on the response is invisible to the
// page rendering that same request, so the very first landing view, the one
// carrying the campaign tags that brought the visitor, would go unrecorded. And
// both cookies are signed, because they are read straight into database columns
// and httpOnly stops JavaScript, not curl.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { NextRequest } from 'next/server';
import { proxy } from '@/proxy';
import {
ANONYMOUS_ID_COOKIE,
FIRST_TOUCH_COOKIE,
readAnonymousIdCookie,
readFirstTouchCookie,
} from '@/lib/analytics/cookies';
const BROWSER_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36';
function documentRequest(
url: string,
init?: { headers?: Record<string, string>; cookies?: Record<string, string> }
) {
const headers = new Headers({
'user-agent': BROWSER_UA,
'sec-fetch-dest': 'document',
...init?.headers,
});
const cookies = Object.entries(init?.cookies ?? {});
if (cookies.length > 0) {
headers.set('cookie', cookies.map(([name, value]) => `${name}=${value}`).join('; '));
}
return new NextRequest(new URL(url), { headers });
}
beforeEach(() => {
vi.stubEnv('NEXTAUTH_SECRET', 'proxy-test-secret');
// getPublicOrigin prefers a configured origin over the request URL, so the
// tests that care about the request URL have to start from neither being set.
vi.stubEnv('NEXTAUTH_URL', undefined);
vi.stubEnv('NEXT_PUBLIC_APP_URL', undefined);
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('proxy', () => {
it('always sets the content security policy', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
const response = await proxy(documentRequest('https://open-frame.net/'));
expect(response.headers.get('Content-Security-Policy')).toContain("default-src 'self'");
});
it('sets no acquisition cookie at all when the flag is off', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
const response = await proxy(documentRequest('https://open-frame.net/?utm_source=github'));
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
expect(response.cookies.get(FIRST_TOUCH_COOKIE)).toBeUndefined();
});
it('gives a new visitor an id and stores what brought them', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const request = documentRequest(
'https://open-frame.net/?utm_source=youtube&utm_medium=video&utm_campaign=launch'
);
const response = await proxy(request);
const cookie = response.cookies.get(ANONYMOUS_ID_COOKIE);
expect(await readAnonymousIdCookie(cookie?.value)).toMatch(/^[a-z0-9]{32}$/);
expect(cookie?.httpOnly).toBe(true);
expect(cookie?.sameSite).toBe('lax');
expect(cookie?.secure).toBe(true);
const touch = await readFirstTouchCookie(response.cookies.get(FIRST_TOUCH_COOKIE)?.value);
expect(touch).toEqual({
channel: 'YOUTUBE',
utmSource: 'youtube',
utmMedium: 'video',
utmCampaign: 'launch',
referrerHost: null,
landingPath: '/',
});
// The page rendering this same request has to be able to read both.
expect(request.cookies.get(ANONYMOUS_ID_COOKIE)?.value).toBe(cookie?.value);
expect(request.cookies.get(FIRST_TOUCH_COOKIE)?.value).toBeDefined();
});
it('classifies a visit that only carries a referrer', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const response = await proxy(
documentRequest('https://open-frame.net/vs/frameio', {
headers: { referer: 'https://github.com/yusufipk/OpenFrame' },
})
);
expect(
await readFirstTouchCookie(response.cookies.get(FIRST_TOUCH_COOKIE)?.value)
).toMatchObject({
channel: 'GITHUB',
referrerHost: 'github.com',
landingPath: '/vs/frameio',
});
});
it('does not overwrite the first touch of a returning visitor', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const issued = await proxy(documentRequest('https://open-frame.net/?utm_source=github'));
const response = await proxy(
documentRequest('https://open-frame.net/?utm_source=google', {
cookies: {
[ANONYMOUS_ID_COOKIE]: issued.cookies.get(ANONYMOUS_ID_COOKIE)?.value ?? '',
[FIRST_TOUCH_COOKIE]: issued.cookies.get(FIRST_TOUCH_COOKIE)?.value ?? '',
},
})
);
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
expect(response.cookies.get(FIRST_TOUCH_COOKIE)).toBeUndefined();
});
it('replaces an id it did not sign, however well formed it looks', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
// The shape a real id has, chosen by the caller rather than issued here.
// Accepting it would let anyone mint visitors, and claim the events of one
// whose id they guessed.
const forged = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
const response = await proxy(
documentRequest('https://open-frame.net/', {
cookies: { [ANONYMOUS_ID_COOKIE]: forged },
})
);
const issued = await readAnonymousIdCookie(response.cookies.get(ANONYMOUS_ID_COOKIE)?.value);
expect(issued).toMatch(/^[a-z0-9]{32}$/);
expect(issued).not.toBe(forged);
});
it('replaces an id signed with another deployment key', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const issued = await proxy(documentRequest('https://open-frame.net/'));
const stolen = issued.cookies.get(ANONYMOUS_ID_COOKIE)?.value ?? '';
vi.stubEnv('NEXTAUTH_SECRET', 'a-different-secret');
const response = await proxy(
documentRequest('https://open-frame.net/', {
cookies: { [ANONYMOUS_ID_COOKIE]: stolen },
})
);
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.value).toBeDefined();
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.value).not.toBe(stolen);
});
it('sets nothing when there is no secret to sign with', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
vi.stubEnv('NEXTAUTH_SECRET', undefined);
vi.stubEnv('AUTH_SECRET', undefined);
const response = await proxy(documentRequest('https://open-frame.net/'));
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
expect(response.cookies.get(FIRST_TOUCH_COOKIE)).toBeUndefined();
});
it('ignores crawlers, so they never enter the visitor count', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const response = await proxy(
documentRequest('https://open-frame.net/', {
headers: { 'user-agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' },
})
);
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
});
it('ignores a prefetch and an API call', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const prefetch = await proxy(
documentRequest('https://open-frame.net/register', {
headers: { 'next-router-prefetch': '1' },
})
);
const api = await proxy(
documentRequest('https://open-frame.net/api/projects', {
headers: { 'sec-fetch-dest': 'empty' },
})
);
expect(prefetch.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
expect(api.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
});
it('leaves the cookie insecure on plain http, so local development works', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const response = await proxy(documentRequest('http://localhost:3000/'));
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.secure).toBe(false);
});
it('keeps the cookie secure behind a TLS-terminating proxy', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
// What a Docker deployment looks like from inside the container: the request
// arrived over http on an internal address, and only the configured origin
// knows the site is served over TLS.
vi.stubEnv('NEXTAUTH_URL', 'https://open-frame.net');
const response = await proxy(documentRequest('http://localhost:3000/'));
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.secure).toBe(true);
});
});