feat(analytics): record where paying customers actually came from

Adds first-party acquisition attribution and a sixteen-event funnel, written to
this deployment's own database and read back on /admin/growth. Nothing is sent
anywhere else, and the whole subsystem is off unless OPENFRAME_ENABLE_ANALYTICS
is set, so a self-hosted instance carries the tables empty and pays nothing.

The proxy gives a visitor an anonymous id and stores what brought them in two
first-party cookies; signup copies that onto the account and claims the events
the visitor produced before they had one, which is what joins the two halves of
the funnel. Recording happens where each step actually happens rather than in
the browser: an ad blocker cannot undercount landing views, and blocking rates
differ by channel, so an undercounted denominator would have made GitHub traffic
look like it converts better than it does.

Every event carries a dedupe key on a UNIQUE column, so "recorded exactly once"
is a property of the schema rather than of fifteen call sites. Subscription
events are derived by comparing the row being overwritten with the row being
written inside the existing Stripe sync, which makes them order-independent and
replay-safe.

The scoreboard reports step-to-step conversion with the denominator beside it,
and splits by source over a rolling 28-day window rather than a week: at this
volume a weekly per-source cell holds single digits, and a percentage computed
from three visits reads exactly as confidently as one computed from three
hundred.

"How did you hear about us?" is asked on the first onboarding screen, not on the
registration form. The number being measured is the signup conversion rate, and
a question added to that form would move it.
This commit is contained in:
yusufipk
2026-08-01 20:00:27 +03:00
parent 93e85683e9
commit 7ca5abd041
48 changed files with 3214 additions and 25 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 # Run once after creating the bucket: bun run r2:configure-cors
# Or set CORS manually in Cloudflare R2 -> bucket -> Settings -> CORS policy. # Or set CORS manually in Cloudflare R2 -> bucket -> Settings -> CORS policy.
OPENFRAME_REQUIRE_INVITE_CODE="true" 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" 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_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_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_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: For self-hosted MinIO behind a reverse proxy, choose one of these browser-facing layouts:
+9
View File
@@ -1,3 +1,6 @@
import { cookies } from 'next/headers';
import { after } from 'next/server';
import { readVisitorContext, recordVisitorEvent } from '@/lib/analytics/visitor';
import { isInviteCodeRequired } from '@/lib/feature-flags'; import { isInviteCodeRequired } from '@/lib/feature-flags';
import { getInvitationPreviewByToken } from '@/lib/invitations'; import { getInvitationPreviewByToken } from '@/lib/invitations';
import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit'; import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit';
@@ -8,6 +11,12 @@ interface RegisterPageProps {
} }
export default async function RegisterPage({ searchParams }: 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 in the
// proxy, so signup starts can never outnumber the landing views above them.
const visitor = readVisitorContext(await cookies());
after(() => recordVisitorEvent('SIGNUP_STARTED', visitor));
const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); 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); const githubEnabled = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
+13 -1
View File
@@ -1,6 +1,9 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { cookies } from 'next/headers';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { after } from 'next/server';
import { ComparisonPage } from '@/components/marketing/comparison-page'; import { ComparisonPage } from '@/components/marketing/comparison-page';
import { readVisitorContext, recordVisitorEvent } from '@/lib/analytics/visitor';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { comparisonPages, getComparisonPage } from '@/lib/marketing/comparison-pages'; import { comparisonPages, getComparisonPage } from '@/lib/marketing/comparison-pages';
import { buildComparisonJsonLd, buildComparisonMetadata } from '@/lib/marketing/metadata'; import { buildComparisonJsonLd, buildComparisonMetadata } from '@/lib/marketing/metadata';
@@ -38,6 +41,15 @@ export default async function MarketingSlugPage({ params }: MarketingSlugPagePro
} }
const session = await auth(); 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 = readVisitorContext(await cookies());
after(() => recordVisitorEvent('LANDING_VIEW', visitor));
}
const structuredData = buildComparisonJsonLd({ const structuredData = buildComparisonJsonLd({
title: page.title, title: page.title,
description: page.metaDescription, description: page.metaDescription,
@@ -57,7 +69,7 @@ export default async function MarketingSlugPage({ params }: MarketingSlugPagePro
}} }}
/> />
))} ))}
<ComparisonPage page={page} isLoggedIn={Boolean(session?.user)} /> <ComparisonPage page={page} isLoggedIn={isLoggedIn} />
</> </>
); );
} }
+358
View File
@@ -0,0 +1,358 @@
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.
</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 { auth } from '@/lib/auth';
import { Header } from '@/components/layout'; import { Header } from '@/components/layout';
import Link from 'next/link'; 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 }) { export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth(); const session = await auth();
@@ -39,6 +39,13 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<MessageSquareQuote className="h-4 w-4" /> <MessageSquareQuote className="h-4 w-4" />
Feedback Feedback
</Link> </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> </nav>
</div> </div>
{/* Desktop Nav */} {/* Desktop Nav */}
@@ -66,6 +73,13 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<MessageSquareQuote className="h-4 w-4" /> <MessageSquareQuote className="h-4 w-4" />
Feedback Feedback
</Link> </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> </nav>
</div> </div>
</aside> </aside>
+35
View File
@@ -0,0 +1,35 @@
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?.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 { rateLimit } from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
type RouteParams = { params: Promise<{ requestId: string }> }; type RouteParams = { params: Promise<{ requestId: string }> };
@@ -202,6 +203,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}); });
if (updated.status === 'APPROVED') { if (updated.status === 'APPROVED') {
await recordEvent({
name: 'APPROVAL_COMPLETED',
dedupeKey: eventKey('APPROVAL_COMPLETED', requestId),
userId: approvalRequest.version.video.project.ownerId,
});
notifyUsers([updated.requestedById], { notifyUsers([updated.requestedById], {
type: 'approval_completed', type: 'approval_completed',
projectName: updated.version.video.project.name, projectName: updated.version.video.project.name,
+10
View File
@@ -17,6 +17,8 @@ import {
sendVerificationEmail, sendVerificationEmail,
} from '@/lib/email-verification'; } from '@/lib/email-verification';
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation'; import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
import { recordSignupCompleted } from '@/lib/analytics/signup';
import { readVisitorContext } from '@/lib/analytics/visitor';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { 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: readVisitorContext(request.cookies),
});
// Send verification email if SMTP is configured // Send verification email if SMTP is configured
if (emailVerificationRequired) { if (emailVerificationRequired) {
const verificationToken = await createVerificationToken(normalizedEmail); 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 { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
import { isTrustedSameOriginRequest } from '@/lib/request-origin'; import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
function getAppOrigin(request: NextRequest) { function getAppOrigin(request: NextRequest) {
if (isTrustedSameOriginRequest(request)) { if (isTrustedSameOriginRequest(request)) {
@@ -84,6 +85,15 @@ export async function POST(request: NextRequest) {
throw new Error('Stripe did not return a checkout URL'); 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 }); const response = successResponse({ url: checkoutSession.url });
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
+36
View File
@@ -0,0 +1,36 @@
import { NextRequest } from 'next/server';
import { rateLimit } from '@/lib/rate-limit';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { readVisitorContext, recordVisitorEvent } from '@/lib/analytics/visitor';
// 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' },
});
const limited = await rateLimit(request, 'analytics-beacon');
if (limited) return limited;
if (!isTrustedSameOriginRequest(request)) 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', readVisitorContext(request.cookies));
return noContent;
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { rateLimit } 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();
}
const limited = await rateLimit(request, 'onboarding-complete');
if (limited) return limited;
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 { rateLimit } from '@/lib/rate-limit';
import { MAX_SHARE_PASSWORD_LENGTH } from '@/lib/share-links'; import { MAX_SHARE_PASSWORD_LENGTH } from '@/lib/share-links';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> }; 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 { 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; if (error) return error;
const body = await request.json().catch(() => ({})); 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'); 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)); const response = successResponse(serializeShareLink(request, videoId, link));
return withCacheControl(response, 'private, no-store'); 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 { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize'; import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
type RouteParams = { params: Promise<{ projectId: string }> }; type RouteParams = { params: Promise<{ projectId: string }> };
@@ -268,6 +269,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}).catch((err) => logError('Notification failed:', err)); }).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); const response = successResponse(video, 201);
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
+10
View File
@@ -7,6 +7,7 @@ import { buildBillingAccessWhereInput } from '@/lib/billing';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags'; import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
// GET /api/projects - List all projects for the authenticated user // GET /api/projects - List all projects for the authenticated user
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -187,6 +188,15 @@ export async function POST(request: NextRequest) {
return createdProject; 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); const response = successResponse(project, 201);
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
@@ -13,6 +13,7 @@ import {
getGuestIdentityFromRequest, getGuestIdentityFromRequest,
setGuestIdentityCookie, setGuestIdentityCookie,
} from '@/lib/guest-identity'; } from '@/lib/guest-identity';
import { eventKey, recordEvent } from '@/lib/analytics/record';
import { import {
extractImageFileNameFromProxyUrl, extractImageFileNameFromProxyUrl,
extractAudioFileNameFromProxyUrl, 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 viewerUserId = session?.user?.id ?? null;
const viewerGuestIdentityId = viewerUserId const viewerGuestIdentityId = viewerUserId
? null ? null
+7
View File
@@ -5,6 +5,7 @@ import { rateLimit } from '@/lib/rate-limit';
import { buildBillingAccessWhereInput, getWorkspaceCreationEligibility } from '@/lib/billing'; import { buildBillingAccessWhereInput, getWorkspaceCreationEligibility } from '@/lib/billing';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response'; import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger'; import { logError } from '@/lib/logger';
import { eventKey, recordEvent } from '@/lib/analytics/record';
// GET /api/workspaces - List all workspaces for the authenticated user // GET /api/workspaces - List all workspaces for the authenticated user
export async function GET(request: NextRequest) { 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); const response = successResponse(workspace, 201);
return withCacheControl(response, 'private, no-store'); return withCacheControl(response, 'private, no-store');
} catch (error) { } catch (error) {
+74 -3
View File
@@ -107,7 +107,45 @@ function ToggleButton({
// ─── Step 1: Welcome ─────────────────────────────────────────────────────────── // ─── 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 ( return (
<div className="text-center space-y-8"> <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"> <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. manage versions, and streamline approvals all in one place.
</p> </p>
</div> </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 Get Started
<ChevronRight className="h-5 w-5 ml-1" /> <ChevronRight className="h-5 w-5 ml-1" />
</Button> </Button>
@@ -691,10 +758,12 @@ export function OnboardingWizard({
userName, userName,
canCreateWorkspace, canCreateWorkspace,
availableWorkspaces, availableWorkspaces,
askAcquisitionSource,
}: { }: {
userName: string; userName: string;
canCreateWorkspace: boolean; canCreateWorkspace: boolean;
availableWorkspaces: Array<{ id: string; name: string; isOwner: boolean }>; availableWorkspaces: Array<{ id: string; name: string; isOwner: boolean }>;
askAcquisitionSource: boolean;
}) { }) {
const router = useRouter(); const router = useRouter();
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
@@ -761,7 +830,9 @@ export function OnboardingWizard({
{/* Step content */} {/* Step content */}
<Card className="border-border/50 shadow-lg"> <Card className="border-border/50 shadow-lg">
<CardContent className="pt-10 pb-10 px-10"> <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 && ( {currentStep === 2 && (
<StepWorkspace <StepWorkspace
canCreateWorkspace={canCreateWorkspace} canCreateWorkspace={canCreateWorkspace}
+2
View File
@@ -1,6 +1,7 @@
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { buildBillingAccessWhereInput, getBillingOverview } from '@/lib/billing'; import { buildBillingAccessWhereInput, getBillingOverview } from '@/lib/billing';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { OnboardingWizard } from './onboarding-wizard'; import { OnboardingWizard } from './onboarding-wizard';
@@ -43,6 +44,7 @@ export default async function OnboardingPage() {
<OnboardingWizard <OnboardingWizard
userName={userName} userName={userName}
canCreateWorkspace={billing.workspaceCreation.canCreateWorkspace} canCreateWorkspace={billing.workspaceCreation.canCreateWorkspace}
askAcquisitionSource={isProductAnalyticsEnabled()}
availableWorkspaces={creatableWorkspaces.map((workspace) => ({ availableWorkspaces={creatableWorkspaces.map((workspace) => ({
id: workspace.id, id: workspace.id,
name: workspace.name, name: workspace.name,
+12 -1
View File
@@ -1,8 +1,19 @@
import { cookies } from 'next/headers';
import { after } from 'next/server';
import { LandingPage } from '@/components/LandingPage'; import { LandingPage } from '@/components/LandingPage';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { readVisitorContext, recordVisitorEvent } from '@/lib/analytics/visitor';
export default async function HomePage() { export default async function HomePage() {
const session = await auth(); 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 = readVisitorContext(await cookies());
after(() => recordVisitorEvent('LANDING_VIEW', visitor));
}
return <LandingPage isLoggedIn={isLoggedIn} />;
} }
+7 -6
View File
@@ -2,6 +2,7 @@
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
import { CtaLink } from '@/components/marketing/cta-link';
import { MarketingCompareLinks } from '@/components/marketing/marketing-compare-links'; import { MarketingCompareLinks } from '@/components/marketing/marketing-compare-links';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { gsap } from 'gsap'; import { gsap } from 'gsap';
@@ -258,13 +259,13 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
data-hero-copy data-hero-copy
className="mx-auto flex max-w-md flex-col items-center justify-center gap-3" className="mx-auto flex max-w-md flex-col items-center justify-center gap-3"
> >
<Link <CtaLink
href={hostedCtaHref} 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]" 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 Start free trial
<MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" /> <MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link> </CtaLink>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
7-day free trial · Flat $10/mo no per-seat fees · No client accounts 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. Need more storage? Add 100 GB for $5/mo.
</p> </p>
<Link <CtaLink
href={hostedCtaHref} 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" 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 Start free trial
</Link> </CtaLink>
</div> </div>
{/* Card 2: Fair Source (Self-hosted) */} {/* Card 2: Fair Source (Self-hosted) */}
@@ -901,12 +902,12 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
Your first review link takes minutes. Your first review link takes minutes.
</p> </p>
</div> </div>
<Link <CtaLink
href={hostedCtaHref} 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]" 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 Start free trial
</Link> </CtaLink>
</div> </div>
</section> </section>
</main> </main>
+5 -4
View File
@@ -1,5 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { ArrowRight, Github, MoveRight } from 'lucide-react'; import { ArrowRight, Github, MoveRight } from 'lucide-react';
import { CtaLink } from '@/components/marketing/cta-link';
import { FeatureComparisonTable } from '@/components/marketing/feature-comparison-table'; import { FeatureComparisonTable } from '@/components/marketing/feature-comparison-table';
import { MarketingFooter } from '@/components/marketing/marketing-footer'; import { MarketingFooter } from '@/components/marketing/marketing-footer';
import { MarketingHeader } from '@/components/marketing/marketing-header'; 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. $10/month hosted plan covers your whole team and every client reviewer link.
</p> </p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row"> <div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link <CtaLink
href={hostedCtaHref} 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]" 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 Start free trial
<MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" /> <MoveRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link> </CtaLink>
<a <a
href={seoConfig.githubUrl} href={seoConfig.githubUrl}
target="_blank" target="_blank"
@@ -199,12 +200,12 @@ export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
</p> </p>
</div> </div>
<div className="flex flex-col gap-3 sm:flex-row"> <div className="flex flex-col gap-3 sm:flex-row">
<Link <CtaLink
href={hostedCtaHref} 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" 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 Start free trial
</Link> </CtaLink>
<a <a
href={seoConfig.githubUrl} href={seoConfig.githubUrl}
target="_blank" 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;
}
+212
View File
@@ -0,0 +1,212 @@
// 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;
}
/** Path only, no query string and no fragment, capped. */
export function sanitizeLandingPath(pathname: string | null | undefined): string {
if (typeof pathname !== 'string' || !pathname.startsWith('/')) return '/';
const path = pathname.split('?')[0]?.split('#')[0] ?? '/';
return path.slice(0, MAX_PATH_LENGTH) || '/';
}
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';
}
+113
View File
@@ -0,0 +1,113 @@
// 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.
//
// 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';
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);
}
/** 26 lowercase base36 characters from the Web Crypto API, which the edge has. */
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;
}
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 encodeURIComponent(JSON.stringify(payload));
}
/**
* Parses the cookie back, re-sanitizing every field.
*
* The cookie is httpOnly but it still came from the client, so a hand-edited one
* must not be able to put arbitrary text into a database column. Anything that
* fails validation makes the whole value null: a half-trusted first touch is
* worse than none.
*/
export function decodeFirstTouch(raw: string | null | undefined): FirstTouch | null {
if (!raw) return null;
let parsed: unknown;
try {
parsed = JSON.parse(decodeURIComponent(raw));
} 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),
};
}
+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);
}
}
+323
View File
@@ -0,0 +1,323 @@
// 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;
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[];
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
`,
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);
}
const accounts: PaidAccountRow[] = paidAccounts.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,
atRisk: accounts.filter(
(account) => !account.lastValueEventAt || account.lastValueEventAt < silentBefore
),
currentActivePaid: stripeStats?.activeSubscribers ?? null,
currentMrrCents: stripeStats?.mrrCents ?? null,
currency: stripeStats?.currency ?? 'usd',
};
}
+48
View File
@@ -0,0 +1,48 @@
// 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 { readVisitorContext, type VisitorContext } from '@/lib/analytics/visitor';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
const NO_VISITOR: VisitorContext = { anonymousId: null, firstTouch: null };
/**
* 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 } = await import('next/headers');
return readVisitorContext(await cookies());
} 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,
});
}
+72
View File
@@ -0,0 +1,72 @@
// 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.
import type { AnalyticsEventName } from '@prisma/client';
import {
ANONYMOUS_ID_COOKIE,
FIRST_TOUCH_COOKIE,
decodeFirstTouch,
isValidAnonymousId,
type FirstTouch,
} from '@/lib/analytics/cookies';
import { dailyEventKey, recordEvent, recordFirstTouch } from '@/lib/analytics/record';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
/** 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;
}
export function readVisitorContext(store: AnalyticsCookieReader): VisitorContext {
const rawId = store.get(ANONYMOUS_ID_COOKIE)?.value;
return {
anonymousId: isValidAnonymousId(rawId) ? rawId : null,
firstTouch: decodeFirstTouch(store.get(FIRST_TOUCH_COOKIE)?.value),
};
}
const DIRECT_TOUCH: FirstTouch = {
channel: 'DIRECT',
utmSource: null,
utmMedium: null,
utmCampaign: null,
referrerHost: null,
landingPath: '/',
};
/**
* 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;
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; 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 { db } from '@/lib/db';
import { getStripe, getStripePriceId } from '@/lib/stripe'; import { getStripe, getStripePriceId } from '@/lib/stripe';
import { isStripeFeatureEnabled } from '@/lib/feature-flags'; import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { recordSubscriptionTransition } from '@/lib/analytics/billing-events';
const ACTIVE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([ const ACTIVE_SUBSCRIPTION_STATUSES = new Set<BillingSubscriptionStatus>([
BillingSubscriptionStatus.ACTIVE, BillingSubscriptionStatus.ACTIVE,
@@ -394,6 +395,10 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
select: { select: {
id: true, id: true,
billingTrialConsumedAt: 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) || (hasActiveSubscription(mappedStatus) ||
Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now())); Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now()));
return db.user.update({ const updated = await db.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
stripeSubscriptionId: subscription.id, stripeSubscriptionId: subscription.id,
@@ -449,6 +454,24 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
: getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null), : 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 // 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({ const user = await db.user.findUnique({
where: { stripeCustomerId: customerId }, where: { stripeCustomerId: customerId },
select: { id: true }, select: {
id: true,
subscriptionStatus: true,
stripeSubscriptionId: true,
stripeCancelAtPeriodEnd: true,
stripeCurrentPeriodEnd: true,
billingTrialConsumedAt: true,
},
}); });
if (!user) { if (!user) {
return null; return null;
} }
return db.user.update({ const updated = await db.user.update({
where: { id: user.id }, where: { id: user.id },
data: { data: {
subscriptionStatus: BillingSubscriptionStatus.CANCELED, subscriptionStatus: BillingSubscriptionStatus.CANCELED,
@@ -544,4 +574,26 @@ export async function markSubscriptionCanceledByCustomerId(
billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(), 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, EMAIL_COLORS,
} from '@/lib/email-brand'; } from '@/lib/email-brand';
import { logError } from '@/lib/logger'; 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. // Reduce window to 2 hours — shorter exposure in access logs and backups.
const TOKEN_EXPIRY_HOURS = 2; 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. // Return null so a replayed/stale token never produces a misleading success redirect.
if (user.count === 0) return null; 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; return record.identifier;
} }
+10
View File
@@ -98,6 +98,16 @@ export function isInviteCodeRequired() {
return readBooleanEnv('OPENFRAME_REQUIRE_INVITE_CODE', true); 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 { function parseBigIntEnv(name: string, defaultValue: bigint, minValue?: bigint): bigint {
const raw = process.env[name]?.trim(); const raw = process.env[name]?.trim();
if (!raw) return defaultValue; if (!raw) return defaultValue;
+3
View File
@@ -105,6 +105,9 @@ export const RATE_LIMIT_CONFIGS: Record<string, RateLimitConfig> = {
// Mutations (update/delete) — moderate // Mutations (update/delete) — moderate
mutate: { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute 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
// General reads — generous // General reads — generous
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
}; };
@@ -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;
+114
View File
@@ -50,6 +50,8 @@ model User {
canceledApprovalRequests ApprovalRequest[] @relation("ApprovalRequestsCanceledBy") canceledApprovalRequests ApprovalRequest[] @relation("ApprovalRequestsCanceledBy")
approvalDecisions ApprovalDecision[] approvalDecisions ApprovalDecision[]
sentInvitations Invitation[] @relation("InvitationsSentBy") sentInvitations Invitation[] @relation("InvitationsSentBy")
acquisition UserAcquisition?
analyticsEvents AnalyticsEvent[]
@@map("users") @@map("users")
} }
@@ -738,6 +740,118 @@ model VideoUploadSession {
@@map("video_upload_sessions") @@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.
//
// No free text from user content is stored. 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.
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) // Rate limiting table (created as UNLOGGED via raw SQL migration)
// Defined here so `prisma db push` doesn't drop it // Defined here so `prisma db push` doesn't drop it
model RateLimit { model RateLimit {
+69 -3
View File
@@ -1,9 +1,75 @@
import { NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { buildContentSecurityPolicy } from '@/lib/content-security-policy'; 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,
encodeFirstTouch,
generateAnonymousId,
isValidAnonymousId,
} from '@/lib/analytics/cookies';
import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
export function proxy() { // Runs on the edge, so nothing here touches the database. It only decides who a
const response = NextResponse.next(); // visitor is and what brought them, then hands both downstream as cookies. The
// rows are written by the pages, which run in Node.
function applyAcquisitionCookies(request: NextRequest, response: NextResponse): 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,
secure: request.nextUrl.protocol === 'https:',
path: '/',
maxAge: ANONYMOUS_ID_MAX_AGE_SECONDS,
};
const existingId = request.cookies.get(ANONYMOUS_ID_COOKIE)?.value;
if (!isValidAnonymousId(existingId)) {
const anonymousId = generateAnonymousId();
// 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, anonymousId);
response.cookies.set(ANONYMOUS_ID_COOKIE, anonymousId, 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 = encodeFirstTouch({
channel: classifyChannel({ utmSource, utmMedium, referrerHost }),
utmSource,
utmMedium,
utmCampaign: sanitizeTag(params.get('utm_campaign')),
referrerHost,
landingPath: sanitizeLandingPath(request.nextUrl.pathname),
});
request.cookies.set(FIRST_TOUCH_COOKIE, firstTouch);
response.cookies.set(FIRST_TOUCH_COOKIE, firstTouch, cookieOptions);
}
export function proxy(request: NextRequest) {
const response = NextResponse.next({ request });
response.headers.set('Content-Security-Policy', buildContentSecurityPolicy()); response.headers.set('Content-Security-Policy', buildContentSecurityPolicy());
applyAcquisitionCookies(request, response);
return response; return response;
} }
+365
View File
@@ -0,0 +1,365 @@
// 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 { encodeFirstTouch, 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 GITHUB_TOUCH: FirstTouch = {
channel: 'GITHUB',
utmSource: 'github',
utmMedium: 'readme',
utmCampaign: null,
referrerHost: 'github.com',
landingPath: '/',
};
function visitorCookies(anonymousId = ANON_ID, touch: FirstTouch = GITHUB_TOUCH) {
return { of_aid: anonymousId, of_ft: encodeFirstTouch(touch) };
}
function beaconRequest(options?: {
name?: string;
origin?: string | null;
cookies?: Record<string, string>;
}) {
const headers: Record<string, string> = {};
const origin = options?.origin === undefined ? ORIGIN : options.origin;
if (origin) headers.origin = origin;
return apiRequest('/api/events', {
body: { name: options?.name ?? 'cta_clicked' },
headers,
cookies: options?.cookies ?? 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');
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe('POST /api/events', () => {
it('records a CTA click and the first touch behind it', async () => {
const response = await callRoute(beacon, 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, beaconRequest());
await callRoute(beacon, beaconRequest());
await callRoute(beacon, beaconRequest());
expect(await db.analyticsEvent.count()).toBe(1);
});
it('counts two different visitors separately', async () => {
await callRoute(beacon, beaconRequest());
await callRoute(
beacon,
beaconRequest({ cookies: 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, beaconRequest());
await callRoute(
beacon,
beaconRequest({
cookies: 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, 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, 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, beaconRequest({ cookies: {} }));
expect(await db.analyticsEvent.count()).toBe(0);
expect(await db.acquisitionTouch.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, 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,
},
cookies: 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, 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 },
});
await recordSignupCompleted({
userId: user.id,
visitor: { anonymousId: ANON_ID, firstTouch: GITHUB_TOUCH },
});
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'; } from '../factories';
import * as adminFeedbackRoute from '@/app/api/admin/feedback/[feedbackId]/route'; 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 adminRefreshR2Route from '@/app/api/admin/stats/refresh-r2/route';
import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/route'; import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/route';
import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/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 feedbackRoute from '@/app/api/feedback/route';
import * as feedbackUploadRoute from '@/app/api/feedback/upload/route'; import * as feedbackUploadRoute from '@/app/api/feedback/upload/route';
import * as onboardingCompleteRoute from '@/app/api/onboarding/complete/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 approvalCandidatesRoute from '@/app/api/projects/[projectId]/approval-candidates/route';
import * as projectDownloadRoute from '@/app/api/projects/[projectId]/download/route'; import * as projectDownloadRoute from '@/app/api/projects/[projectId]/download/route';
import * as projectInvitationRoute from '@/app/api/projects/[projectId]/members/invitations/[invitationId]/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 // The count guard
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES. // 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 * 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. // so it cannot be used to enumerate accounts.
'resend of the verification email, for users who cannot sign in yet', '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', 'stripe/webhook/route.ts',
// Called by Stripe, not by a browser. Authenticated by the HMAC signature // 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}`, url: (f) => `/api/admin/feedback/${f.feedbackId}`,
params: (f) => ({ feedbackId: 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', file: 'admin/stats/refresh-r2/route.ts',
module: adminRefreshR2Route, module: adminRefreshR2Route,
@@ -405,6 +421,12 @@ const ROUTE_CASES: readonly RouteCase[] = [
module: onboardingCompleteRoute, module: onboardingCompleteRoute,
url: () => '/api/onboarding/complete', 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', file: 'projects/[projectId]/approval-candidates/route.ts',
module: approvalCandidatesRoute, module: approvalCandidatesRoute,
+1
View File
@@ -66,6 +66,7 @@ const REVIEWED_MIGRATIONS = [
'20260613120000_add_r2_video_asset_provider', '20260613120000_add_r2_video_asset_provider',
'20260614160000_add_project_allow_downloads', '20260614160000_add_project_allow_downloads',
'20260627140000_add_video_upload_multipart_id', '20260627140000_add_video_upload_multipart_id',
'20260801120000_add_acquisition_analytics',
]; ];
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */ /** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
+130
View File
@@ -0,0 +1,130 @@
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('/');
});
});
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');
});
});
+137
View File
@@ -0,0 +1,137 @@
import { describe, it, expect } from 'vitest';
import {
decodeFirstTouch,
encodeFirstTouch,
generateAnonymousId,
isAcquisitionChannel,
isValidAnonymousId,
type FirstTouch,
} from '@/lib/analytics/cookies';
import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots';
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', () => {
const forged = encodeURIComponent(JSON.stringify({ c: 'INVESTOR_DEMO', p: '/' }));
expect(decodeFirstTouch(forged)).toBeNull();
});
it('re-sanitizes fields rather than trusting the cookie', () => {
const forged = encodeURIComponent(
JSON.stringify({ c: 'DIRECT', p: '/x', s: '<script>alert(1)</script>', r: 'not a host' })
);
const decoded = decodeFirstTouch(forged);
expect(decoded?.utmSource).toBeNull();
expect(decoded?.referrerHost).toBeNull();
});
it('returns null for garbage and for an absent cookie', () => {
expect(decodeFirstTouch('%%%not-json%%%')).toBeNull();
expect(decodeFirstTouch(null)).toBeNull();
expect(decodeFirstTouch(encodeURIComponent(JSON.stringify(['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);
});
});
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);
});
});
+163
View File
@@ -0,0 +1,163 @@
// 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.
//
// The load-bearing detail below is that the id is written to the *request* as
// well as the response. 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.
import { describe, it, expect, afterEach, vi } from 'vitest';
import { NextRequest } from 'next/server';
import { proxy } from '@/proxy';
import { ANONYMOUS_ID_COOKIE, FIRST_TOUCH_COOKIE, decodeFirstTouch } 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 });
}
afterEach(() => {
vi.unstubAllEnvs();
});
describe('proxy', () => {
it('always sets the content security policy', () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
const response = 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', () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
const response = 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', () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const request = documentRequest(
'https://open-frame.net/?utm_source=youtube&utm_medium=video&utm_campaign=launch'
);
const response = proxy(request);
const id = response.cookies.get(ANONYMOUS_ID_COOKIE);
expect(id?.value).toMatch(/^[a-z0-9]{32}$/);
expect(id?.httpOnly).toBe(true);
expect(id?.sameSite).toBe('lax');
expect(id?.secure).toBe(true);
const touch = decodeFirstTouch(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(id?.value);
expect(request.cookies.get(FIRST_TOUCH_COOKIE)?.value).toBeDefined();
});
it('classifies a visit that only carries a referrer', () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const response = proxy(
documentRequest('https://open-frame.net/vs/frameio', {
headers: { referer: 'https://github.com/yusufipk/OpenFrame' },
})
);
expect(decodeFirstTouch(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', () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const existingId = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
const response = proxy(
documentRequest('https://open-frame.net/?utm_source=google', {
cookies: { [ANONYMOUS_ID_COOKIE]: existingId, [FIRST_TOUCH_COOKIE]: 'anything' },
})
);
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
expect(response.cookies.get(FIRST_TOUCH_COOKIE)).toBeUndefined();
});
it('replaces an id that does not look like one we issued', () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const response = proxy(
documentRequest('https://open-frame.net/', {
cookies: { [ANONYMOUS_ID_COOKIE]: 'nope' },
})
);
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.value).toMatch(/^[a-z0-9]{32}$/);
});
it('ignores crawlers, so they never enter the visitor count', () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const response = 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', () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const prefetch = proxy(
documentRequest('https://open-frame.net/register', {
headers: { 'next-router-prefetch': '1' },
})
);
const api = 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', () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const response = proxy(documentRequest('http://localhost:3000/'));
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.secure).toBe(false);
});
});