From 7ca5abd041add2e471837c779d01fb4d3d8ad6e7 Mon Sep 17 00:00:00 2001
From: yusufipk
Date: Sat, 1 Aug 2026 20:00:27 +0300
Subject: [PATCH 1/2] 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.
---
.env.example | 4 +
README.md | 1 +
app/(auth)/register/page.tsx | 9 +
app/(marketing)/[slug]/page.tsx | 14 +-
app/admin/growth/page.tsx | 358 +++++++++++++++++
app/admin/layout.tsx | 16 +-
app/api/admin/growth/route.ts | 35 ++
.../approvals/[requestId]/decision/route.ts | 7 +
app/api/auth/register/route.ts | 10 +
app/api/billing/checkout/route.ts | 10 +
app/api/events/route.ts | 36 ++
app/api/onboarding/source/route.ts | 40 ++
.../videos/[videoId]/share/route.ts | 15 +-
app/api/projects/[projectId]/videos/route.ts | 7 +
app/api/projects/route.ts | 10 +
.../versions/[versionId]/comments/route.ts | 13 +
app/api/workspaces/route.ts | 7 +
app/onboarding/onboarding-wizard.tsx | 77 +++-
app/onboarding/page.tsx | 2 +
app/page.tsx | 13 +-
components/LandingPage.tsx | 13 +-
components/marketing/comparison-page.tsx | 9 +-
components/marketing/cta-link.tsx | 30 ++
lib/analytics/billing-events.ts | 88 +++++
lib/analytics/bots.ts | 42 ++
lib/analytics/channel.ts | 212 ++++++++++
lib/analytics/cookies.ts | 113 ++++++
lib/analytics/record.ts | 183 +++++++++
lib/analytics/scoreboard.ts | 323 ++++++++++++++++
lib/analytics/signup.ts | 48 +++
lib/analytics/visitor.ts | 72 ++++
lib/auth.ts | 15 +
lib/billing.ts | 58 ++-
lib/client/track.ts | 31 ++
lib/email-verification.ts | 19 +
lib/feature-flags.ts | 10 +
lib/rate-limit.ts | 3 +
.../migration.sql | 87 +++++
prisma/schema.prisma | 116 +++++-
proxy.ts | 72 +++-
tests/api/analytics-events.test.ts | 365 ++++++++++++++++++
tests/api/analytics-scoreboard.test.ts | 155 ++++++++
tests/api/auth-matrix.test.ts | 24 +-
tests/setup/db-global.ts | 1 +
tests/unit/lib/analytics-channel.test.ts | 130 +++++++
tests/unit/lib/analytics-cookies.test.ts | 137 +++++++
tests/unit/lib/analytics-scoreboard.test.ts | 36 ++
tests/unit/proxy.test.ts | 163 ++++++++
48 files changed, 3214 insertions(+), 25 deletions(-)
create mode 100644 app/admin/growth/page.tsx
create mode 100644 app/api/admin/growth/route.ts
create mode 100644 app/api/events/route.ts
create mode 100644 app/api/onboarding/source/route.ts
create mode 100644 components/marketing/cta-link.tsx
create mode 100644 lib/analytics/billing-events.ts
create mode 100644 lib/analytics/bots.ts
create mode 100644 lib/analytics/channel.ts
create mode 100644 lib/analytics/cookies.ts
create mode 100644 lib/analytics/record.ts
create mode 100644 lib/analytics/scoreboard.ts
create mode 100644 lib/analytics/signup.ts
create mode 100644 lib/analytics/visitor.ts
create mode 100644 lib/client/track.ts
create mode 100644 prisma/migrations/20260801120000_add_acquisition_analytics/migration.sql
create mode 100644 tests/api/analytics-events.test.ts
create mode 100644 tests/api/analytics-scoreboard.test.ts
create mode 100644 tests/unit/lib/analytics-channel.test.ts
create mode 100644 tests/unit/lib/analytics-cookies.test.ts
create mode 100644 tests/unit/lib/analytics-scoreboard.test.ts
create mode 100644 tests/unit/proxy.test.ts
diff --git a/.env.example b/.env.example
index fa63f41..5d0d2f2 100644
--- a/.env.example
+++ b/.env.example
@@ -34,6 +34,10 @@ OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES="33554432"
# Run once after creating the bucket: bun run r2:configure-cors
# Or set CORS manually in Cloudflare R2 -> bucket -> Settings -> CORS policy.
OPENFRAME_REQUIRE_INVITE_CODE="true"
+# Acquisition attribution and funnel events, read back on /admin/growth. Off by
+# default: the rows only pay for themselves if you are running a signup funnel.
+# Everything is written to this instance's own database and sent nowhere.
+OPENFRAME_ENABLE_ANALYTICS="false"
SELF_HOSTED_AUTO_CREATE_BUCKET="false"
# ============================================================================
diff --git a/README.md b/README.md
index adc1b64..79f6dfd 100644
--- a/README.md
+++ b/README.md
@@ -180,6 +180,7 @@ Behavior when disabled:
- `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` hides Bunny direct-upload entry points. URL-based providers such as YouTube remain available.
- `OPENFRAME_ENABLE_S3_VIDEO_UPLOADS=true` (with `R2_*` configured) enables presigned uploads to your own S3-compatible storage. Set `OPENFRAME_ENABLE_BUNNY_UPLOADS=false` — only one direct-upload backend can be active. The bucket must allow CORS `PUT` from your app origin (for example `http://localhost:3000` in dev and your production URL). For Docker + MinIO, keep `R2_ENDPOINT=http://minio:9000` (app-internal) and set `R2_PRESIGN_ENDPOINT` to the browser-reachable MinIO origin (for example `http://localhost:9000` locally, or `https://minio.example.com` when MinIO is behind a reverse proxy). Use the origin only — no path suffix. The app's Content-Security-Policy is generated from runtime env at request time, so published Docker images pick up custom `R2_PRESIGN_ENDPOINT` values without rebuilding or editing `next.config.ts`.
- `OPENFRAME_REQUIRE_INVITE_CODE=false` allows open registration while keeping invitation-link registration intact.
+- `OPENFRAME_ENABLE_ANALYTICS=true` records first-touch attribution and funnel events into your own database, readable on `/admin/growth`. Off by default, and nothing leaves the instance either way.
For self-hosted MinIO behind a reverse proxy, choose one of these browser-facing layouts:
diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx
index 43a012a..2610263 100644
--- a/app/(auth)/register/page.tsx
+++ b/app/(auth)/register/page.tsx
@@ -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 { getInvitationPreviewByToken } from '@/lib/invitations';
import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit';
@@ -8,6 +11,12 @@ interface RegisterPageProps {
}
export default async function RegisterPage({ searchParams }: RegisterPageProps) {
+ // Reaching this page is the funnel step. Recording it here rather than from the
+ // browser also keeps it honest: a prefetch of this route is filtered 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 githubEnabled = Boolean(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
diff --git a/app/(marketing)/[slug]/page.tsx b/app/(marketing)/[slug]/page.tsx
index 4e0c393..6a5b0b5 100644
--- a/app/(marketing)/[slug]/page.tsx
+++ b/app/(marketing)/[slug]/page.tsx
@@ -1,6 +1,9 @@
import type { Metadata } from 'next';
+import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';
+import { after } from 'next/server';
import { ComparisonPage } from '@/components/marketing/comparison-page';
+import { readVisitorContext, recordVisitorEvent } from '@/lib/analytics/visitor';
import { auth } from '@/lib/auth';
import { comparisonPages, getComparisonPage } from '@/lib/marketing/comparison-pages';
import { buildComparisonJsonLd, buildComparisonMetadata } from '@/lib/marketing/metadata';
@@ -38,6 +41,15 @@ export default async function MarketingSlugPage({ params }: MarketingSlugPagePro
}
const session = await auth();
+ const isLoggedIn = Boolean(session?.user);
+
+ // A comparison page is a landing page: for most of these visitors it is the
+ // first thing they see, so it belongs in the same visitor count as `/`.
+ if (!isLoggedIn) {
+ const visitor = readVisitorContext(await cookies());
+ after(() => recordVisitorEvent('LANDING_VIEW', visitor));
+ }
+
const structuredData = buildComparisonJsonLd({
title: page.title,
description: page.metaDescription,
@@ -57,7 +69,7 @@ export default async function MarketingSlugPage({ params }: MarketingSlugPagePro
}}
/>
))}
-
+
>
);
}
diff --git a/app/admin/growth/page.tsx b/app/admin/growth/page.tsx
new file mode 100644
index 0000000..58d34ab
--- /dev/null
+++ b/app/admin/growth/page.tsx
@@ -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 — ;
+ return (
+
+ {Math.round(rate * 100)}% /{of}
+
+ );
+}
+
+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 (
+
+
Growth
+
+
+ Acquisition tracking is off on this deployment. Set{' '}
+ OPENFRAME_ENABLE_ANALYTICS=true to start recording
+ the funnel. Nothing is collected until you do, and nothing is ever sent anywhere but
+ this instance's own database.
+
+
+
+ );
+ }
+
+ 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 (
+
+
+
Growth
+
+
+
+
+
+ Active paid
+
+
+
+ {scoreboard.currentActivePaid ?? '—'}
+ from Stripe, right now
+
+
+
+
+ MRR
+
+
+
+
+ {formatMoney(scoreboard.currentMrrCents, scoreboard.currency)}
+
+ from Stripe, right now
+
+
+
+
+ Visitors this week
+
+
+
+ {latest?.visitors ?? 0}
+
+ {latest ? `week of ${formatWeek(latest.weekStart)}` : 'no data yet'}
+
+
+
+
+
+ At risk
+
+
+
+ {scoreboard.atRisk.length}
+
+ paid, silent for {AT_RISK_SILENT_DAYS}+ days
+
+
+
+
+
+
+
+ Weekly funnel
+
+ 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.
+
+
+
+
+
+
+ Week
+ {WEEK_COLUMNS.map((column) => (
+
+ {column.label}
+
+ ))}
+ MRR
+
+
+
+ {scoreboard.weeks.map((week) => (
+
+ {formatWeek(week.weekStart)}
+ {WEEK_COLUMNS.map((column) => (
+
+ {week[column.key as keyof typeof week] as number}
+
+ ))}
+
+ {formatMoney(week.mrrCents, scoreboard.currency)}
+
+
+ ))}
+
+
+
+
+
+
+
+ Where it narrows
+
+ Every step over the whole {scoreboard.weeks.length}-week window, with the denominator
+ beside it. The lowest rate is the step to work on.
+
+
+
+
+
Visitor to signup
+
+
+
+
+
+
Signup to first video
+
+
+
+
+
+
Video to share link
+
+
+
+
+
+
Share to outside feedback
+
+
+
+
+
+
+
+
+
+
+ By source
+
+ 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.
+
+
+
+
+
+
+ Source
+ Visitors
+ Signup
+ Trial
+ Paid
+ Visitor to signup
+
+
+
+ {scoreboard.channels.length === 0 && (
+
+
+ Nothing recorded in this window yet.
+
+
+ )}
+ {scoreboard.channels.map((row) => (
+
+ {row.channel.toLowerCase()}
+ {row.visitors}
+ {row.signups}
+ {row.trials}
+ {row.paid}
+
+ 0 ? row.signups / row.visitors : null}
+ of={row.visitors}
+ />
+
+
+ ))}
+
+
+
+
+
+
+
+ Paid accounts
+
+ Value events are videos, share links, outside feedback, approvals and projects. Rows
+ marked at risk have produced none for {AT_RISK_SILENT_DAYS} days.
+
+
+
+
+
+
+ Account
+ Status
+ Source
+ 7d
+ 30d
+ Last activity
+
+
+
+ {scoreboard.paidAccounts.length === 0 && (
+
+
+ No active or trialing accounts.
+
+
+ )}
+ {scoreboard.paidAccounts.map((account) => {
+ const atRisk = scoreboard.atRisk.some((row) => row.userId === account.userId);
+ return (
+
+
+ {account.name || account.email || account.userId}
+ {atRisk && (
+
+ at risk
+
+ )}
+
+
+ {account.status.toLowerCase()}
+
+
+ {account.channel?.toLowerCase() ?? '—'}
+ {account.selfReported && account.selfReported !== account.channel && (
+
+ {' '}
+ (said {account.selfReported.toLowerCase()})
+
+ )}
+
+ {account.valueEvents7}
+ {account.valueEvents30}
+
+ {formatDate(account.lastValueEventAt)}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx
index 8ac9288..a57dcbe 100644
--- a/app/admin/layout.tsx
+++ b/app/admin/layout.tsx
@@ -2,7 +2,7 @@ import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
import { Header } from '@/components/layout';
import Link from 'next/link';
-import { LayoutDashboard, MessageSquareQuote, Users } from 'lucide-react';
+import { LayoutDashboard, MessageSquareQuote, TrendingUp, Users } from 'lucide-react';
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
@@ -39,6 +39,13 @@ export default async function AdminLayout({ children }: { children: React.ReactN
Feedback
+
+
+ Growth
+
{/* Desktop Nav */}
@@ -66,6 +73,13 @@ export default async function AdminLayout({ children }: { children: React.ReactN
Feedback
+
+
+ Growth
+
diff --git a/app/api/admin/growth/route.ts b/app/api/admin/growth/route.ts
new file mode 100644
index 0000000..7a710dd
--- /dev/null
+++ b/app/api/admin/growth/route.ts
@@ -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');
+ }
+}
diff --git a/app/api/approvals/[requestId]/decision/route.ts b/app/api/approvals/[requestId]/decision/route.ts
index f5ecf6a..71b141f 100644
--- a/app/api/approvals/[requestId]/decision/route.ts
+++ b/app/api/approvals/[requestId]/decision/route.ts
@@ -6,6 +6,7 @@ import { notifyUsers } from '@/lib/notifications';
import { rateLimit } from '@/lib/rate-limit';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger';
+import { eventKey, recordEvent } from '@/lib/analytics/record';
type RouteParams = { params: Promise<{ requestId: string }> };
@@ -202,6 +203,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
});
if (updated.status === 'APPROVED') {
+ await recordEvent({
+ name: 'APPROVAL_COMPLETED',
+ dedupeKey: eventKey('APPROVAL_COMPLETED', requestId),
+ userId: approvalRequest.version.video.project.ownerId,
+ });
+
notifyUsers([updated.requestedById], {
type: 'approval_completed',
projectName: updated.version.video.project.name,
diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts
index 7c28ba9..e7c671b 100644
--- a/app/api/auth/register/route.ts
+++ b/app/api/auth/register/route.ts
@@ -17,6 +17,8 @@ import {
sendVerificationEmail,
} from '@/lib/email-verification';
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) {
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
if (emailVerificationRequired) {
const verificationToken = await createVerificationToken(normalizedEmail);
diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts
index b28cb83..81c44dd 100644
--- a/app/api/billing/checkout/route.ts
+++ b/app/api/billing/checkout/route.ts
@@ -11,6 +11,7 @@ import { isStripeFeatureEnabled } from '@/lib/feature-flags';
import { getStripe, getStripePriceId, isStripeConfigured } from '@/lib/stripe';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
import { logError } from '@/lib/logger';
+import { eventKey, recordEvent } from '@/lib/analytics/record';
function getAppOrigin(request: NextRequest) {
if (isTrustedSameOriginRequest(request)) {
@@ -84,6 +85,15 @@ export async function POST(request: NextRequest) {
throw new Error('Stripe did not return a checkout URL');
}
+ // Keyed on the Stripe session, so an abandoned checkout followed by a second
+ // attempt counts twice. That is the intent: the gap between checkouts started
+ // and subscriptions started is the number worth watching.
+ await recordEvent({
+ name: 'CHECKOUT_STARTED',
+ dedupeKey: eventKey('CHECKOUT_STARTED', checkoutSession.id),
+ userId: session.user.id,
+ });
+
const response = successResponse({ url: checkoutSession.url });
return withCacheControl(response, 'private, no-store');
} catch (error) {
diff --git a/app/api/events/route.ts b/app/api/events/route.ts
new file mode 100644
index 0000000..417680e
--- /dev/null
+++ b/app/api/events/route.ts
@@ -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;
+}
diff --git a/app/api/onboarding/source/route.ts b/app/api/onboarding/source/route.ts
new file mode 100644
index 0000000..c4cab9f
--- /dev/null
+++ b/app/api/onboarding/source/route.ts
@@ -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');
+}
diff --git a/app/api/projects/[projectId]/videos/[videoId]/share/route.ts b/app/api/projects/[projectId]/videos/[videoId]/share/route.ts
index 52d5fca..66d3971 100644
--- a/app/api/projects/[projectId]/videos/[videoId]/share/route.ts
+++ b/app/api/projects/[projectId]/videos/[videoId]/share/route.ts
@@ -8,6 +8,7 @@ import { db } from '@/lib/db';
import { rateLimit } from '@/lib/rate-limit';
import { MAX_SHARE_PASSWORD_LENGTH } from '@/lib/share-links';
import { logError } from '@/lib/logger';
+import { eventKey, recordEvent } from '@/lib/analytics/record';
type RouteParams = { params: Promise<{ projectId: string; videoId: string }> };
@@ -141,7 +142,11 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
const { projectId, videoId } = await params;
- const { error } = await requireShareManagementAccess(projectId, videoId, session.user.id);
+ const { error, video } = await requireShareManagementAccess(
+ projectId,
+ videoId,
+ session.user.id
+ );
if (error) return error;
const body = await request.json().catch(() => ({}));
@@ -244,6 +249,14 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.internalError('Failed to create video share link');
}
+ // Keyed on the link id, so re-issuing the token for a link that already
+ // exists updates the row and records nothing: the share was created once.
+ await recordEvent({
+ name: 'SHARE_LINK_CREATED',
+ dedupeKey: eventKey('SHARE_LINK_CREATED', link.id),
+ userId: video?.project.ownerId ?? null,
+ });
+
const response = successResponse(serializeShareLink(request, videoId, link));
return withCacheControl(response, 'private, no-store');
diff --git a/app/api/projects/[projectId]/videos/route.ts b/app/api/projects/[projectId]/videos/route.ts
index a56fa73..cad3a12 100644
--- a/app/api/projects/[projectId]/videos/route.ts
+++ b/app/api/projects/[projectId]/videos/route.ts
@@ -8,6 +8,7 @@ import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response
import { verifyBunnyUploadToken } from '@/lib/bunny-upload-token';
import { finalizeR2VideoUpload } from '@/lib/r2-video-finalize';
import { logError } from '@/lib/logger';
+import { eventKey, recordEvent } from '@/lib/analytics/record';
type RouteParams = { params: Promise<{ projectId: string }> };
@@ -268,6 +269,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}).catch((err) => logError('Notification failed:', err));
}
+ await recordEvent({
+ name: 'VIDEO_ADDED',
+ dedupeKey: eventKey('VIDEO_ADDED', video.id),
+ userId: project.ownerId,
+ });
+
const response = successResponse(video, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts
index 3c28ae0..f35ad8d 100644
--- a/app/api/projects/route.ts
+++ b/app/api/projects/route.ts
@@ -7,6 +7,7 @@ import { buildBillingAccessWhereInput } from '@/lib/billing';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { DEFAULT_COMMENT_TAGS } from '@/lib/comment-tags';
import { logError } from '@/lib/logger';
+import { eventKey, recordEvent } from '@/lib/analytics/record';
// GET /api/projects - List all projects for the authenticated user
export async function GET(request: NextRequest) {
@@ -187,6 +188,15 @@ export async function POST(request: NextRequest) {
return createdProject;
});
+ // Attributed to the workspace owner rather than the caller: the funnel asks
+ // which account is progressing, and a team member creating a project moves
+ // the owner's account, not their own.
+ await recordEvent({
+ name: 'PROJECT_CREATED',
+ dedupeKey: eventKey('PROJECT_CREATED', project.id),
+ userId: workspace.ownerId,
+ });
+
const response = successResponse(project, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts
index cf889f7..b236f1b 100644
--- a/app/api/versions/[versionId]/comments/route.ts
+++ b/app/api/versions/[versionId]/comments/route.ts
@@ -13,6 +13,7 @@ import {
getGuestIdentityFromRequest,
setGuestIdentityCookie,
} from '@/lib/guest-identity';
+import { eventKey, recordEvent } from '@/lib/analytics/record';
import {
extractImageFileNameFromProxyUrl,
extractAudioFileNameFromProxyUrl,
@@ -547,6 +548,18 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
}
}
+ // Feedback arriving from outside the team is the moment this product
+ // becomes worth paying for, so it is the activation step of the funnel.
+ // Keyed on the account, not the comment: what matters is the first time an
+ // account ever received one.
+ if (isGuest) {
+ await recordEvent({
+ name: 'FIRST_GUEST_COMMENT',
+ dedupeKey: eventKey('FIRST_GUEST_COMMENT', project.workspace.ownerId),
+ userId: project.workspace.ownerId,
+ });
+ }
+
const viewerUserId = session?.user?.id ?? null;
const viewerGuestIdentityId = viewerUserId
? null
diff --git a/app/api/workspaces/route.ts b/app/api/workspaces/route.ts
index 83de26f..79ba35d 100644
--- a/app/api/workspaces/route.ts
+++ b/app/api/workspaces/route.ts
@@ -5,6 +5,7 @@ import { rateLimit } from '@/lib/rate-limit';
import { buildBillingAccessWhereInput, getWorkspaceCreationEligibility } from '@/lib/billing';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
import { logError } from '@/lib/logger';
+import { eventKey, recordEvent } from '@/lib/analytics/record';
// GET /api/workspaces - List all workspaces for the authenticated user
export async function GET(request: NextRequest) {
@@ -136,6 +137,12 @@ export async function POST(request: NextRequest) {
},
});
+ await recordEvent({
+ name: 'WORKSPACE_CREATED',
+ dedupeKey: eventKey('WORKSPACE_CREATED', workspace.id),
+ userId: workspace.ownerId,
+ });
+
const response = successResponse(workspace, 201);
return withCacheControl(response, 'private, no-store');
} catch (error) {
diff --git a/app/onboarding/onboarding-wizard.tsx b/app/onboarding/onboarding-wizard.tsx
index c152b45..1c853e2 100644
--- a/app/onboarding/onboarding-wizard.tsx
+++ b/app/onboarding/onboarding-wizard.tsx
@@ -107,7 +107,45 @@ function ToggleButton({
// ─── Step 1: Welcome ───────────────────────────────────────────────────────────
-function StepWelcome({ userName, onNext }: { userName: string; onNext: () => void }) {
+// Asked here rather than on the registration form. The whole point of measuring
+// this funnel is the signup conversion rate, and a question added to the form
+// would move the number being measured.
+const SOURCE_OPTIONS: Array<{ value: string; label: string }> = [
+ { value: 'GITHUB', label: 'GitHub' },
+ { value: 'YOUTUBE', label: 'YouTube' },
+ { value: 'GOOGLE', label: 'A search engine' },
+ { value: 'REVIEW_LINK', label: 'A review or comparison site' },
+ { value: 'REFERRAL', label: 'Someone recommended it' },
+ { value: 'COMMUNITY', label: 'Reddit, X, Discord or a forum' },
+ { value: 'OUTBOUND', label: 'An email from us' },
+ { value: 'OTHER', label: 'Somewhere else' },
+];
+
+function StepWelcome({
+ userName,
+ askSource,
+ onNext,
+}: {
+ userName: string;
+ askSource: boolean;
+ onNext: () => void;
+}) {
+ const [source, setSource] = useState('');
+ 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 (
@@ -122,7 +160,36 @@ function StepWelcome({ userName, onNext }: { userName: string; onNext: () => voi
manage versions, and streamline approvals — all in one place.
-
+
+ {askSource && (
+
+
+ How did you hear about us? (optional)
+
+
+
+
+
+
+ {SOURCE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ {source === 'OTHER' && (
+ setNote(event.target.value)}
+ maxLength={200}
+ placeholder="Where, roughly?"
+ />
+ )}
+
+ )}
+
+
Get Started
@@ -691,10 +758,12 @@ export function OnboardingWizard({
userName,
canCreateWorkspace,
availableWorkspaces,
+ askAcquisitionSource,
}: {
userName: string;
canCreateWorkspace: boolean;
availableWorkspaces: Array<{ id: string; name: string; isOwner: boolean }>;
+ askAcquisitionSource: boolean;
}) {
const router = useRouter();
const [currentStep, setCurrentStep] = useState(1);
@@ -761,7 +830,9 @@ export function OnboardingWizard({
{/* Step content */}
- {currentStep === 1 && }
+ {currentStep === 1 && (
+
+ )}
{currentStep === 2 && (
({
id: workspace.id,
name: workspace.name,
diff --git a/app/page.tsx b/app/page.tsx
index c8e9008..f7a2285 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,8 +1,19 @@
+import { cookies } from 'next/headers';
+import { after } from 'next/server';
import { LandingPage } from '@/components/LandingPage';
import { auth } from '@/lib/auth';
+import { readVisitorContext, recordVisitorEvent } from '@/lib/analytics/visitor';
export default async function HomePage() {
const session = await auth();
+ const isLoggedIn = Boolean(session?.user);
- return ;
+ // 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 ;
}
diff --git a/components/LandingPage.tsx b/components/LandingPage.tsx
index 1e9c7f0..213a409 100644
--- a/components/LandingPage.tsx
+++ b/components/LandingPage.tsx
@@ -2,6 +2,7 @@
import Image from 'next/image';
import Link from 'next/link';
+import { CtaLink } from '@/components/marketing/cta-link';
import { MarketingCompareLinks } from '@/components/marketing/marketing-compare-links';
import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
@@ -258,13 +259,13 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
data-hero-copy
className="mx-auto flex max-w-md flex-col items-center justify-center gap-3"
>
-
Start free trial
-
+
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.
-
Start free trial
-
+
{/* Card 2: Fair Source (Self-hosted) */}
@@ -901,12 +902,12 @@ export function LandingPage({ isLoggedIn }: LandingPageProps) {
Your first review link takes minutes.
-
Start free trial
-
+
diff --git a/components/marketing/comparison-page.tsx b/components/marketing/comparison-page.tsx
index 7c7a431..c99660a 100644
--- a/components/marketing/comparison-page.tsx
+++ b/components/marketing/comparison-page.tsx
@@ -1,5 +1,6 @@
import Link from 'next/link';
import { ArrowRight, Github, MoveRight } from 'lucide-react';
+import { CtaLink } from '@/components/marketing/cta-link';
import { FeatureComparisonTable } from '@/components/marketing/feature-comparison-table';
import { MarketingFooter } from '@/components/marketing/marketing-footer';
import { MarketingHeader } from '@/components/marketing/marketing-header';
@@ -41,13 +42,13 @@ export function ComparisonPage({ page, isLoggedIn }: ComparisonPageProps) {
$10/month hosted plan covers your whole team and every client reviewer link.
-
Start free trial
-
+
+ {children}
+
+ );
+}
diff --git a/lib/analytics/billing-events.ts b/lib/analytics/billing-events.ts
new file mode 100644
index 0000000..e7fc5f9
--- /dev/null
+++ b/lib/analytics/billing-events.ts
@@ -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 {
+ 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,
+ });
+ }
+}
diff --git a/lib/analytics/bots.ts b/lib/analytics/bots.ts
new file mode 100644
index 0000000..0bf06e3
--- /dev/null
+++ b/lib/analytics/bots.ts
@@ -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;
+}
diff --git a/lib/analytics/channel.ts b/lib/analytics/channel.ts
new file mode 100644
index 0000000..342e4b1
--- /dev/null
+++ b/lib/analytics/channel.ts
@@ -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 = 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 = 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.` 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';
+}
diff --git a/lib/analytics/cookies.ts b/lib/analytics/cookies.ts
new file mode 100644
index 0000000..ee16352
--- /dev/null
+++ b/lib/analytics/cookies.ts
@@ -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;
+
+ 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),
+ };
+}
diff --git a/lib/analytics/record.ts b/lib/analytics/record.ts
new file mode 100644
index 0000000..cc73239
--- /dev/null
+++ b/lib/analytics/record.ts
@@ -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;
+}
+
+/** `:`, for something that can only ever happen once per subject. */
+export function eventKey(name: AnalyticsEventName, subject: string): string {
+ return `${name}:${subject}`;
+}
+
+/**
+ * `::`, 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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);
+ }
+}
diff --git a/lib/analytics/scoreboard.ts b/lib/analytics/scoreboard.ts
new file mode 100644
index 0000000..3ec846a
--- /dev/null
+++ b/lib/analytics/scoreboard.ts
@@ -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 = {
+ 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 {
+ 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`
+ 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`
+ 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>`
+ 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`
+ 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();
+ 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();
+ 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',
+ };
+}
diff --git a/lib/analytics/signup.ts b/lib/analytics/signup.ts
new file mode 100644
index 0000000..142fd77
--- /dev/null
+++ b/lib/analytics/signup.ts
@@ -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 {
+ 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 {
+ 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,
+ });
+}
diff --git a/lib/analytics/visitor.ts b/lib/analytics/visitor.ts
new file mode 100644
index 0000000..734ec77
--- /dev/null
+++ b/lib/analytics/visitor.ts
@@ -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 {
+ 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,
+ });
+}
diff --git a/lib/auth.ts b/lib/auth.ts
index 5f3880d..5d8dad5 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -150,6 +150,21 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
return token;
},
},
+ events: {
+ // The OAuth half of signup. Accounts created by Google or GitHub are written
+ // by the Prisma adapter and never reach app/api/auth/register, so recording
+ // the event only there would have made every social signup invisible in the
+ // funnel while looking like it worked.
+ async createUser({ user }) {
+ if (!user.id) return;
+ const { recordSignupCompleted, readVisitorContextFromHeaders } =
+ await import('@/lib/analytics/signup');
+ await recordSignupCompleted({
+ userId: user.id,
+ visitor: await readVisitorContextFromHeaders(),
+ });
+ },
+ },
});
// ---------------------------------------------------------------------------
diff --git a/lib/billing.ts b/lib/billing.ts
index 082001e..3b67130 100644
--- a/lib/billing.ts
+++ b/lib/billing.ts
@@ -4,6 +4,7 @@ import { BillingSubscriptionStatus } from '@prisma/client';
import { db } from '@/lib/db';
import { getStripe, getStripePriceId } from '@/lib/stripe';
import { isStripeFeatureEnabled } from '@/lib/feature-flags';
+import { recordSubscriptionTransition } from '@/lib/analytics/billing-events';
const ACTIVE_SUBSCRIPTION_STATUSES = new Set([
BillingSubscriptionStatus.ACTIVE,
@@ -394,6 +395,10 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
select: {
id: true,
billingTrialConsumedAt: true,
+ // Read for the funnel: the transition is what gets recorded, so the state
+ // being overwritten has to be captured before the update below.
+ subscriptionStatus: true,
+ stripeCancelAtPeriodEnd: true,
},
});
@@ -430,7 +435,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
(hasActiveSubscription(mappedStatus) ||
Boolean(currentPeriodEnd && currentPeriodEnd * 1000 > Date.now()));
- return db.user.update({
+ const updated = await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
@@ -449,6 +454,24 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
: getInactiveBillingAccessEndedAt(subscription, hasEntitledPrice ? currentPeriodEnd : null),
},
});
+
+ await recordSubscriptionTransition({
+ userId: user.id,
+ subscriptionId: subscription.id,
+ before: {
+ status: user.subscriptionStatus,
+ cancelAtPeriodEnd: user.stripeCancelAtPeriodEnd,
+ hadTrial: user.billingTrialConsumedAt !== null,
+ },
+ after: {
+ status: mappedStatus,
+ cancelAtPeriodEnd,
+ trialEndsAt: effectiveTrialEnd,
+ currentPeriodEnd: effectiveCurrentPeriodEnd,
+ },
+ });
+
+ return updated;
}
// A single Stripe customer can own several subscriptions at once (e.g. after
@@ -524,14 +547,21 @@ export async function markSubscriptionCanceledByCustomerId(
) {
const user = await db.user.findUnique({
where: { stripeCustomerId: customerId },
- select: { id: true },
+ select: {
+ id: true,
+ subscriptionStatus: true,
+ stripeSubscriptionId: true,
+ stripeCancelAtPeriodEnd: true,
+ stripeCurrentPeriodEnd: true,
+ billingTrialConsumedAt: true,
+ },
});
if (!user) {
return null;
}
- return db.user.update({
+ const updated = await db.user.update({
where: { id: user.id },
data: {
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
@@ -544,4 +574,26 @@ export async function markSubscriptionCanceledByCustomerId(
billingAccessEndedAt: options?.endedAt ?? options?.currentPeriodEnd ?? new Date(),
},
});
+
+ // Reached when the customer has no subscriptions left at all. The cycle marker
+ // uses the period end being cleared here, which is the same one the earlier
+ // "cancel at period end" write carried, so a customer who cancelled through the
+ // portal and then reached the end of their term produces one cancellation, not two.
+ await recordSubscriptionTransition({
+ userId: user.id,
+ subscriptionId: user.stripeSubscriptionId ?? user.id,
+ before: {
+ status: user.subscriptionStatus,
+ cancelAtPeriodEnd: user.stripeCancelAtPeriodEnd,
+ hadTrial: user.billingTrialConsumedAt !== null,
+ },
+ after: {
+ status: BillingSubscriptionStatus.CANCELED,
+ cancelAtPeriodEnd: false,
+ trialEndsAt: null,
+ currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null,
+ },
+ });
+
+ return updated;
}
diff --git a/lib/client/track.ts b/lib/client/track.ts
new file mode 100644
index 0000000..88ae685
--- /dev/null
+++ b/lib/client/track.ts
@@ -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.
+ }
+}
diff --git a/lib/email-verification.ts b/lib/email-verification.ts
index 875d6ff..a8c6b8c 100644
--- a/lib/email-verification.ts
+++ b/lib/email-verification.ts
@@ -9,6 +9,8 @@ import {
EMAIL_COLORS,
} from '@/lib/email-brand';
import { logError } from '@/lib/logger';
+import { eventKey, recordEvent } from '@/lib/analytics/record';
+import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
// Reduce window to 2 hours — shorter exposure in access logs and backups.
const TOKEN_EXPIRY_HOURS = 2;
@@ -77,6 +79,23 @@ export async function consumeVerificationToken(token: string): Promise 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;
}
diff --git a/lib/feature-flags.ts b/lib/feature-flags.ts
index 00c1eb3..9bea836 100644
--- a/lib/feature-flags.ts
+++ b/lib/feature-flags.ts
@@ -98,6 +98,16 @@ export function isInviteCodeRequired() {
return readBooleanEnv('OPENFRAME_REQUIRE_INVITE_CODE', true);
}
+// Acquisition attribution and funnel events. Defaults to OFF, unlike the other
+// product flags here, because the cost of the two mistakes is not symmetric: a
+// hosted instance that forgets to switch it on shows an empty growth page and is
+// noticed the same day, while a self-hosted instance that gets it silently
+// switched on accumulates rows nobody asked for. Nothing is ever sent off the
+// instance either way, so this is about cost, not disclosure.
+export function isProductAnalyticsEnabled() {
+ return readBooleanEnv('OPENFRAME_ENABLE_ANALYTICS', false);
+}
+
function parseBigIntEnv(name: string, defaultValue: bigint, minValue?: bigint): bigint {
const raw = process.env[name]?.trim();
if (!raw) return defaultValue;
diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts
index 37017e6..011b767 100644
--- a/lib/rate-limit.ts
+++ b/lib/rate-limit.ts
@@ -105,6 +105,9 @@ export const RATE_LIMIT_CONFIGS: Record = {
// Mutations (update/delete) — moderate
mutate: { windowMs: 60 * 1000, maxRequests: 30 }, // 30 per minute
+ // Analytics beacon — anonymous and public, so bound it per IP
+ 'analytics-beacon': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
+
// General reads — generous
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
};
diff --git a/prisma/migrations/20260801120000_add_acquisition_analytics/migration.sql b/prisma/migrations/20260801120000_add_acquisition_analytics/migration.sql
new file mode 100644
index 0000000..ebffb2d
--- /dev/null
+++ b/prisma/migrations/20260801120000_add_acquisition_analytics/migration.sql
@@ -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;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 8fc215b..0f6f42b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -50,7 +50,9 @@ model User {
canceledApprovalRequests ApprovalRequest[] @relation("ApprovalRequestsCanceledBy")
approvalDecisions ApprovalDecision[]
sentInvitations Invitation[] @relation("InvitationsSentBy")
-
+ acquisition UserAcquisition?
+ analyticsEvents AnalyticsEvent[]
+
@@map("users")
}
@@ -738,6 +740,118 @@ model VideoUploadSession {
@@map("video_upload_sessions")
}
+// ============================================
+// ACQUISITION + PRODUCT ANALYTICS
+// ============================================
+//
+// Everything here is first party. Rows are written to this deployment's own
+// database and read back by this deployment's own admin panel; no part of the
+// codebase ships them anywhere else. The whole subsystem is off unless
+// OPENFRAME_ENABLE_ANALYTICS is set, so a self-hosted instance carries the
+// tables empty and pays nothing for them.
+//
+// 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)
// Defined here so `prisma db push` doesn't drop it
model RateLimit {
diff --git a/proxy.ts b/proxy.ts
index 52d5ac4..be1a726 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -1,9 +1,75 @@
-import { NextResponse } from 'next/server';
+import { NextRequest, NextResponse } from 'next/server';
import { buildContentSecurityPolicy } from '@/lib/content-security-policy';
+import {
+ classifyChannel,
+ extractReferrerHost,
+ sanitizeLandingPath,
+ sanitizeTag,
+} from '@/lib/analytics/channel';
+import {
+ ANONYMOUS_ID_COOKIE,
+ ANONYMOUS_ID_MAX_AGE_SECONDS,
+ FIRST_TOUCH_COOKIE,
+ encodeFirstTouch,
+ generateAnonymousId,
+ isValidAnonymousId,
+} from '@/lib/analytics/cookies';
+import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots';
+import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
-export function proxy() {
- const response = NextResponse.next();
+// Runs on the edge, so nothing here touches the database. It only decides who a
+// visitor is and what brought them, then hands both downstream as 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());
+ applyAcquisitionCookies(request, response);
return response;
}
diff --git a/tests/api/analytics-events.test.ts b/tests/api/analytics-events.test.ts
new file mode 100644
index 0000000..5cfced9
--- /dev/null
+++ b/tests/api/analytics-events.test.ts
@@ -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;
+}) {
+ const headers: Record = {};
+ 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 {
+ 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('attributed@example.com');
+ expect(response.status).toBe(201);
+
+ const user = await db.user.findUniqueOrThrow({ where: { email: 'attributed@example.com' } });
+ 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('backfilled@example.com');
+
+ const user = await db.user.findUniqueOrThrow({ where: { email: 'backfilled@example.com' } });
+ 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('unmeasured@example.com');
+
+ 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);
+ });
+});
diff --git a/tests/api/analytics-scoreboard.test.ts b/tests/api/analytics-scoreboard.test.ts
new file mode 100644
index 0000000..d128614
--- /dev/null
+++ b/tests/api/analytics-scoreboard.test.ts
@@ -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);
+ });
+});
diff --git a/tests/api/auth-matrix.test.ts b/tests/api/auth-matrix.test.ts
index 14fa3cd..6be06e2 100644
--- a/tests/api/auth-matrix.test.ts
+++ b/tests/api/auth-matrix.test.ts
@@ -44,6 +44,7 @@ import {
} from '../factories';
import * as adminFeedbackRoute from '@/app/api/admin/feedback/[feedbackId]/route';
+import * as adminGrowthRoute from '@/app/api/admin/growth/route';
import * as adminRefreshR2Route from '@/app/api/admin/stats/refresh-r2/route';
import * as approvalCancelRoute from '@/app/api/approvals/[requestId]/cancel/route';
import * as approvalDecisionRoute from '@/app/api/approvals/[requestId]/decision/route';
@@ -54,6 +55,7 @@ import * as commentRoute from '@/app/api/comments/[commentId]/route';
import * as feedbackRoute from '@/app/api/feedback/route';
import * as feedbackUploadRoute from '@/app/api/feedback/upload/route';
import * as onboardingCompleteRoute from '@/app/api/onboarding/complete/route';
+import * as onboardingSourceRoute from '@/app/api/onboarding/source/route';
import * as approvalCandidatesRoute from '@/app/api/projects/[projectId]/approval-candidates/route';
import * as projectDownloadRoute from '@/app/api/projects/[projectId]/download/route';
import * as projectInvitationRoute from '@/app/api/projects/[projectId]/members/invitations/[invitationId]/route';
@@ -143,7 +145,7 @@ vi.mock('@/lib/r2', async (importOriginal) => {
// The count guard
// ---------------------------------------------------------------------------
// Bump this only together with a new entry in ROUTE_CASES or in PUBLIC_ROUTES.
-const EXPECTED_ROUTE_MODULE_COUNT = 60;
+const EXPECTED_ROUTE_MODULE_COUNT = 63;
/**
* Routes that are public by design, and why. Everything else must reject an
@@ -176,6 +178,15 @@ const PUBLIC_ROUTES: ReadonlyMap = new Map([
// so it cannot be used to enumerate accounts.
'resend of the verification email, for users who cannot sign in yet',
],
+ [
+ 'events/route.ts',
+ // The CTA-click beacon. Its whole job is to hear from visitors who have no
+ // account yet, so a session cannot be the guard. It is bounded three ways
+ // instead: same-origin only, IP rate limited, and it accepts exactly one
+ // event name, so nothing a caller sends can forge a signup or a payment.
+ // Covered in tests/api/analytics-events.test.ts.
+ 'anonymous CTA beacon, restricted to one event name and to same-origin callers',
+ ],
[
'stripe/webhook/route.ts',
// Called by Stripe, not by a browser. Authenticated by the HMAC signature
@@ -350,6 +361,11 @@ const ROUTE_CASES: readonly RouteCase[] = [
url: (f) => `/api/admin/feedback/${f.feedbackId}`,
params: (f) => ({ feedbackId: f.feedbackId }),
},
+ {
+ file: 'admin/growth/route.ts',
+ module: adminGrowthRoute,
+ url: () => '/api/admin/growth',
+ },
{
file: 'admin/stats/refresh-r2/route.ts',
module: adminRefreshR2Route,
@@ -405,6 +421,12 @@ const ROUTE_CASES: readonly RouteCase[] = [
module: onboardingCompleteRoute,
url: () => '/api/onboarding/complete',
},
+ {
+ file: 'onboarding/source/route.ts',
+ module: onboardingSourceRoute,
+ url: () => '/api/onboarding/source',
+ body: { source: 'GITHUB' },
+ },
{
file: 'projects/[projectId]/approval-candidates/route.ts',
module: approvalCandidatesRoute,
diff --git a/tests/setup/db-global.ts b/tests/setup/db-global.ts
index 3df05c5..ce68aed 100644
--- a/tests/setup/db-global.ts
+++ b/tests/setup/db-global.ts
@@ -66,6 +66,7 @@ const REVIEWED_MIGRATIONS = [
'20260613120000_add_r2_video_asset_provider',
'20260614160000_add_project_allow_downloads',
'20260627140000_add_video_upload_multipart_id',
+ '20260801120000_add_acquisition_analytics',
];
/** Objects POST_PUSH_SQL must have produced. Verified after it runs. */
diff --git a/tests/unit/lib/analytics-channel.test.ts b/tests/unit/lib/analytics-channel.test.ts
new file mode 100644
index 0000000..3a197f3
--- /dev/null
+++ b/tests/unit/lib/analytics-channel.test.ts
@@ -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('', 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);
+ });
+});
diff --git a/tests/unit/lib/analytics-scoreboard.test.ts b/tests/unit/lib/analytics-scoreboard.test.ts
new file mode 100644
index 0000000..92c224e
--- /dev/null
+++ b/tests/unit/lib/analytics-scoreboard.test.ts
@@ -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);
+ });
+});
diff --git a/tests/unit/proxy.test.ts b/tests/unit/proxy.test.ts
new file mode 100644
index 0000000..b0320ef
--- /dev/null
+++ b/tests/unit/proxy.test.ts
@@ -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; cookies?: Record }
+) {
+ 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);
+ });
+});
From 33c845636c3903ff277d5eccdef7a34e24d448a3 Mon Sep 17 00:00:00 2001
From: yusufipk
Date: Sat, 1 Aug 2026 20:29:28 +0300
Subject: [PATCH 2/2] fix(analytics): sign the acquisition cookies and bound
what they can write
Both cookies were read straight into database columns after nothing more than a
format check. httpOnly keeps JavaScript out of them and does nothing about curl,
so the anonymous id was a string the caller picked: enough to write a first-touch
row for a visitor who never existed, to file it under a channel of their
choosing, and to claim that id's events at signup, since the backfill matches on
the id alone.
They are now signed with an HMAC over AUTH_SECRET, through Web Crypto rather than
node:crypto because the proxy runs on the edge and the pages that read the
cookies back run in Node. The first-touch body moved to base64url on the way:
cookie values are percent-encoded and decoded by several layers that do not agree
on how many times, and a payload carrying its own percent escapes comes back
subtly different and takes the signature with it.
Signing stops a caller choosing an id, not collecting one, since dropping the
cookie and asking for the landing page again mints another. So the bot and
prefetch filters moved to where the rows are written rather than only where the
cookies are issued, which also fixes a returning visitor's prefetch of /register
recording a signup start, and a per-client hourly ceiling now sits in front of
the write. The ceiling is skipped when TRUSTED_PROXY_MODE is unset, where every
caller resolves to 127.0.0.1 and the bucket would empty on real traffic long
before it emptied on a flood.
Four smaller things around it:
- /api/events checked the flag and the origin after paying for a rate-limit
write, so a host who never turned analytics on was still writing a row per
anonymous POST. Both checks are free and now come first, and the limiter
answers 204 rather than 429: a beacon has nobody to tell, and a flooder should
not be handed the reset time.
- /api/onboarding/source was keyed by IP on an authenticated route. Without
TRUSTED_PROXY_MODE that is five answers an hour for the whole deployment, and
with it a shared office address locks out everyone after one colleague
answered. Keyed by account, like /api/onboarding/complete beside it.
- The cookies took their Secure flag from request.nextUrl.protocol, which behind
a TLS-terminating reverse proxy is the container-internal http address. It
comes off the configured public origin now.
- sanitizeLandingPath took anything that started with a slash, including from the
cookie, so a hand-written one could put newlines and markup into a column an
admin table may render one day.
Also: the paid-account query had no LIMIT and returned every active account's
name and email, the growth route answered 403 where it meant 401, and the schema
claimed no free text is stored when self_reported_note holds 200 characters of it.
---
app/(auth)/register/page.tsx | 10 +-
app/(marketing)/[slug]/page.tsx | 5 +-
app/admin/growth/page.tsx | 7 ++
app/api/admin/growth/route.ts | 5 +-
app/api/auth/register/route.ts | 4 +-
app/api/events/route.ts | 18 ++-
app/api/onboarding/source/route.ts | 20 +++-
app/page.tsx | 5 +-
lib/analytics/channel.ts | 19 +++-
lib/analytics/cookies.ts | 83 ++++++++++++--
lib/analytics/scoreboard.ts | 20 +++-
lib/analytics/signing.ts | 115 +++++++++++++++++++
lib/analytics/signup.ts | 8 +-
lib/analytics/visitor.ts | 99 +++++++++++++++-
lib/rate-limit.ts | 26 ++++-
prisma/schema.prisma | 7 +-
proxy.ts | 39 ++++---
tests/api/analytics-events.test.ts | 96 ++++++++++++----
tests/unit/lib/analytics-channel.test.ts | 14 +++
tests/unit/lib/analytics-cookies.test.ts | 102 +++++++++++++++--
tests/unit/proxy.test.ts | 138 +++++++++++++++++------
21 files changed, 711 insertions(+), 129 deletions(-)
create mode 100644 lib/analytics/signing.ts
diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx
index 2610263..84930c1 100644
--- a/app/(auth)/register/page.tsx
+++ b/app/(auth)/register/page.tsx
@@ -1,6 +1,5 @@
-import { cookies } from 'next/headers';
import { after } from 'next/server';
-import { readVisitorContext, recordVisitorEvent } from '@/lib/analytics/visitor';
+import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
import { isInviteCodeRequired } from '@/lib/feature-flags';
import { getInvitationPreviewByToken } from '@/lib/invitations';
import { isInvitationPreviewAllowed } from '@/lib/invitation-preview-limit';
@@ -12,9 +11,10 @@ interface RegisterPageProps {
export default async function RegisterPage({ searchParams }: RegisterPageProps) {
// Reaching this page is the funnel step. Recording it here rather than from the
- // browser also keeps it honest: a prefetch of this route is filtered in the
- // proxy, so signup starts can never outnumber the landing views above them.
- const visitor = readVisitorContext(await cookies());
+ // browser also keeps it honest: a prefetch of this route is filtered out by
+ // readPageVisitor, so signup starts can never outnumber the landing views
+ // above them.
+ const visitor = await readPageVisitor();
after(() => recordVisitorEvent('SIGNUP_STARTED', visitor));
const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
diff --git a/app/(marketing)/[slug]/page.tsx b/app/(marketing)/[slug]/page.tsx
index 6a5b0b5..27691af 100644
--- a/app/(marketing)/[slug]/page.tsx
+++ b/app/(marketing)/[slug]/page.tsx
@@ -1,9 +1,8 @@
import type { Metadata } from 'next';
-import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';
import { after } from 'next/server';
import { ComparisonPage } from '@/components/marketing/comparison-page';
-import { readVisitorContext, recordVisitorEvent } from '@/lib/analytics/visitor';
+import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
import { auth } from '@/lib/auth';
import { comparisonPages, getComparisonPage } from '@/lib/marketing/comparison-pages';
import { buildComparisonJsonLd, buildComparisonMetadata } from '@/lib/marketing/metadata';
@@ -46,7 +45,7 @@ export default async function MarketingSlugPage({ params }: MarketingSlugPagePro
// 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());
+ const visitor = await readPageVisitor();
after(() => recordVisitorEvent('LANDING_VIEW', visitor));
}
diff --git a/app/admin/growth/page.tsx b/app/admin/growth/page.tsx
index 58d34ab..003cb1e 100644
--- a/app/admin/growth/page.tsx
+++ b/app/admin/growth/page.tsx
@@ -295,6 +295,13 @@ export default async function AdminGrowthPage() {
Value events are videos, share links, outside feedback, approvals and projects. Rows
marked at risk have produced none for {AT_RISK_SILENT_DAYS} days.
+ {scoreboard.paidAccountsTruncated && (
+ <>
+ {' '}
+ Quietest {scoreboard.paidAccountLimit} only; there are more paid accounts than this
+ table shows.
+ >
+ )}
diff --git a/app/api/admin/growth/route.ts b/app/api/admin/growth/route.ts
index 7a710dd..2e75810 100644
--- a/app/api/admin/growth/route.ts
+++ b/app/api/admin/growth/route.ts
@@ -10,7 +10,10 @@ import { logError } from '@/lib/logger';
export async function GET(request: NextRequest) {
try {
const session = await auth();
- if (!session?.user?.isAdmin) {
+ if (!session?.user?.id) {
+ return apiErrors.unauthorized();
+ }
+ if (!session.user.isAdmin) {
return apiErrors.forbidden('Admin access required');
}
diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts
index e7c671b..801a8e3 100644
--- a/app/api/auth/register/route.ts
+++ b/app/api/auth/register/route.ts
@@ -18,7 +18,7 @@ import {
} from '@/lib/email-verification';
import { isValidEmailAddress, normalizeEmail } from '@/lib/email-validation';
import { recordSignupCompleted } from '@/lib/analytics/signup';
-import { readVisitorContext } from '@/lib/analytics/visitor';
+import { readRequestVisitor } from '@/lib/analytics/visitor';
export async function POST(request: NextRequest) {
try {
@@ -139,7 +139,7 @@ export async function POST(request: NextRequest) {
// been accepted, so an account that gets rolled back never leaves a signup.
await recordSignupCompleted({
userId: user.id,
- visitor: readVisitorContext(request.cookies),
+ visitor: await readRequestVisitor(request),
});
// Send verification email if SMTP is configured
diff --git a/app/api/events/route.ts b/app/api/events/route.ts
index 417680e..262686d 100644
--- a/app/api/events/route.ts
+++ b/app/api/events/route.ts
@@ -1,7 +1,8 @@
import { NextRequest } from 'next/server';
import { rateLimit } from '@/lib/rate-limit';
import { isTrustedSameOriginRequest } from '@/lib/request-origin';
-import { readVisitorContext, recordVisitorEvent } from '@/lib/analytics/visitor';
+import { readRequestVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
+import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
// The one funnel event that cannot be observed from the server: a click on a
// call to action, which never reaches us as a request of its own.
@@ -21,16 +22,23 @@ export async function POST(request: NextRequest) {
headers: { 'Cache-Control': 'private, no-store' },
});
- const limited = await rateLimit(request, 'analytics-beacon');
- if (limited) return limited;
-
+ // Both cheap and both free of side effects, so they come before the limiter.
+ // Checking the flag here rather than only inside the recorder keeps a host who
+ // never turned analytics on from paying a rate-limit write for every anonymous
+ // POST to an endpoint they are not using.
+ if (!isProductAnalyticsEnabled()) return noContent;
if (!isTrustedSameOriginRequest(request)) return noContent;
+ // 204 rather than the limiter's 429: a beacon has nobody to tell, and a
+ // flooder should not be handed a signal for when the window resets.
+ const limited = await rateLimit(request, 'analytics-beacon');
+ if (limited) return noContent;
+
const body = await request.json().catch(() => null);
const name = typeof body?.name === 'string' ? body.name : '';
if (!ALLOWED_EVENTS.has(name)) return noContent;
- await recordVisitorEvent('CTA_CLICKED', readVisitorContext(request.cookies));
+ await recordVisitorEvent('CTA_CLICKED', await readRequestVisitor(request));
return noContent;
}
diff --git a/app/api/onboarding/source/route.ts b/app/api/onboarding/source/route.ts
index c4cab9f..fd1c811 100644
--- a/app/api/onboarding/source/route.ts
+++ b/app/api/onboarding/source/route.ts
@@ -1,7 +1,7 @@
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 { RATE_LIMIT_CONFIGS, checkRateLimit, rateLimitHeaders } from '@/lib/rate-limit';
import { setSelfReportedSource } from '@/lib/analytics/record';
import { isAcquisitionChannel } from '@/lib/analytics/cookies';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
@@ -18,8 +18,22 @@ export async function POST(request: NextRequest) {
return apiErrors.unauthorized();
}
- const limited = await rateLimit(request, 'onboarding-complete');
- if (limited) return limited;
+ // Keyed by account, like /api/onboarding/complete beside it. An IP key would
+ // be the wrong bucket twice over: without TRUSTED_PROXY_MODE every caller
+ // resolves to 127.0.0.1, so five answers an hour would be five for the whole
+ // deployment, and with it a shared office address would lock out everyone
+ // after one colleague answered.
+ const config = RATE_LIMIT_CONFIGS['onboarding-source'];
+ const limit = await checkRateLimit(session.user.id, 'onboarding-source', config);
+ if (!limit.allowed) {
+ return new Response(JSON.stringify({ error: 'Too many requests. Please try again later.' }), {
+ status: 429,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...rateLimitHeaders(limit, config.maxRequests),
+ },
+ });
+ }
if (!isProductAnalyticsEnabled()) {
return apiErrors.badRequest('Analytics are disabled by this host');
diff --git a/app/page.tsx b/app/page.tsx
index f7a2285..7ddc5f0 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,8 +1,7 @@
-import { cookies } from 'next/headers';
import { after } from 'next/server';
import { LandingPage } from '@/components/LandingPage';
import { auth } from '@/lib/auth';
-import { readVisitorContext, recordVisitorEvent } from '@/lib/analytics/visitor';
+import { readPageVisitor, recordVisitorEvent } from '@/lib/analytics/visitor';
export default async function HomePage() {
const session = await auth();
@@ -11,7 +10,7 @@ export default async function HomePage() {
// 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());
+ const visitor = await readPageVisitor();
after(() => recordVisitorEvent('LANDING_VIEW', visitor));
}
diff --git a/lib/analytics/channel.ts b/lib/analytics/channel.ts
index 342e4b1..0062865 100644
--- a/lib/analytics/channel.ts
+++ b/lib/analytics/channel.ts
@@ -75,11 +75,24 @@ export function extractReferrerHost(
return host;
}
-/** Path only, no query string and no fragment, capped. */
+// What a URL path is allowed to be made of, per RFC 3986: unreserved characters,
+// percent escapes, sub-delims and the separators. Everything a real route can
+// carry, and nothing that survives being pasted into a page or a log line.
+const LANDING_PATH_PATTERN = /^\/[A-Za-z0-9\-._~%!$&'()*+,;=:@/]*$/;
+
+/**
+ * Path only, no query string and no fragment, capped and character-checked.
+ *
+ * The proxy feeds this `request.nextUrl.pathname`, which is already a path. The
+ * cookie reader feeds it whatever the cookie said, which is why the allowlist is
+ * here rather than left to the caller: an unchecked value would put newlines and
+ * markup into a column that some later admin table renders.
+ */
export function sanitizeLandingPath(pathname: string | null | undefined): string {
if (typeof pathname !== 'string' || !pathname.startsWith('/')) return '/';
- const path = pathname.split('?')[0]?.split('#')[0] ?? '/';
- return path.slice(0, MAX_PATH_LENGTH) || '/';
+ const path = (pathname.split('?')[0]?.split('#')[0] ?? '/').slice(0, MAX_PATH_LENGTH);
+ if (!path || !LANDING_PATH_PATTERN.test(path)) return '/';
+ return path;
}
function suffixMatch(host: string, domain: string): boolean {
diff --git a/lib/analytics/cookies.ts b/lib/analytics/cookies.ts
index ee16352..999035f 100644
--- a/lib/analytics/cookies.ts
+++ b/lib/analytics/cookies.ts
@@ -5,11 +5,16 @@
// YouTube link on Tuesday and signs up on Friday is still counted against
// YouTube; there is no cross-site identifier and nothing is sent anywhere.
//
+// Both are also signed. Nothing here trusts a cookie it did not issue: read
+// through `readAnonymousIdCookie` and `readFirstTouchCookie`, never through the
+// `decode` helpers, which are the unsigned inner layer.
+//
// Imported by the proxy, so this file must stay free of Prisma and of anything
// else that cannot run on the edge.
import type { AcquisitionChannel } from '@prisma/client';
import { sanitizeLandingPath, sanitizeTag, normalizeHost } from '@/lib/analytics/channel';
+import { signCookieValue, unsignCookieValue } from '@/lib/analytics/signing';
export const ANONYMOUS_ID_COOKIE = 'of_aid';
export const FIRST_TOUCH_COOKIE = 'of_ft';
@@ -58,7 +63,7 @@ export function isValidAnonymousId(value: string | null | undefined): value is s
return typeof value === 'string' && ANONYMOUS_ID_PATTERN.test(value);
}
-/** 26 lowercase base36 characters from the Web Crypto API, which the edge has. */
+/** 128 bits from the Web Crypto API, which the edge has, as 32 base36 characters. */
export function generateAnonymousId(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
@@ -69,29 +74,58 @@ export function generateAnonymousId(): string {
return id;
}
+// base64url rather than encodeURIComponent, and not for compactness. Cookie
+// values are percent-encoded on the way out and decoded on the way back, by
+// several layers that do not all agree on how many times; a payload that already
+// contains percent escapes comes back subtly different and takes the signature
+// down with it. base64url has nothing either layer wants to touch.
+function toBase64Url(text: string): string {
+ let binary = '';
+ for (const byte of new TextEncoder().encode(text)) {
+ binary += String.fromCharCode(byte);
+ }
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+function fromBase64Url(value: string): string | null {
+ try {
+ const padded = value.replace(/-/g, '+').replace(/_/g, '/');
+ const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, '='));
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
+ return new TextDecoder().decode(bytes);
+ } catch {
+ return null;
+ }
+}
+
export function encodeFirstTouch(touch: FirstTouch): string {
const payload: EncodedFirstTouch = { c: touch.channel, p: touch.landingPath };
if (touch.utmSource) payload.s = touch.utmSource;
if (touch.utmMedium) payload.m = touch.utmMedium;
if (touch.utmCampaign) payload.k = touch.utmCampaign;
if (touch.referrerHost) payload.r = touch.referrerHost;
- return encodeURIComponent(JSON.stringify(payload));
+ return toBase64Url(JSON.stringify(payload));
}
/**
- * Parses the cookie back, re-sanitizing every field.
+ * Parses the cookie body 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.
+ * The second line of defence, not the first: callers go through
+ * `readFirstTouchCookie`, which checks the signature before this ever runs. The
+ * re-sanitizing stays because a value that survives both checks can still be one
+ * this deployment signed a year ago, under an older set of rules. Anything that
+ * fails validation makes the whole value null, since a half-trusted first touch
+ * is worse than none.
*/
export function decodeFirstTouch(raw: string | null | undefined): FirstTouch | null {
if (!raw) return null;
+ const json = fromBase64Url(raw);
+ if (!json) return null;
+
let parsed: unknown;
try {
- parsed = JSON.parse(decodeURIComponent(raw));
+ parsed = JSON.parse(json);
} catch {
return null;
}
@@ -111,3 +145,36 @@ export function decodeFirstTouch(raw: string | null | undefined): FirstTouch | n
landingPath: sanitizeLandingPath(value.p),
};
}
+
+// ---------------------------------------------------------------------------
+// The signed forms, which are the only ones anything outside this file uses.
+// ---------------------------------------------------------------------------
+
+/** The cookie value to set, or null when there is no secret to sign it with. */
+export function signAnonymousId(anonymousId: string): Promise {
+ return signCookieValue(anonymousId);
+}
+
+export function signFirstTouch(touch: FirstTouch): Promise {
+ return signCookieValue(encodeFirstTouch(touch));
+}
+
+/**
+ * The anonymous id this deployment issued, or null.
+ *
+ * Null covers every failure the same way: no cookie, a cookie signed with
+ * another key, one edited by hand, one whose id no longer matches the shape we
+ * mint. A visitor we cannot vouch for is not counted rather than counted wrong.
+ */
+export async function readAnonymousIdCookie(
+ raw: string | null | undefined
+): Promise {
+ const anonymousId = await unsignCookieValue(raw);
+ return isValidAnonymousId(anonymousId) ? anonymousId : null;
+}
+
+export async function readFirstTouchCookie(
+ raw: string | null | undefined
+): Promise {
+ return decodeFirstTouch(await unsignCookieValue(raw));
+}
diff --git a/lib/analytics/scoreboard.ts b/lib/analytics/scoreboard.ts
index 3ec846a..de29580 100644
--- a/lib/analytics/scoreboard.ts
+++ b/lib/analytics/scoreboard.ts
@@ -29,6 +29,16 @@ export const AT_RISK_SILENT_DAYS = 14;
const DEFAULT_WEEKS = 12;
const CHANNEL_WINDOW_DAYS = 28;
+/**
+ * How many paid accounts the per-account table carries.
+ *
+ * The list is ordered quietest first, so the cap drops the accounts that are
+ * using the product most, which are the ones nobody needs to read a row about.
+ * It is reported rather than applied silently: a truncated table that looks
+ * complete is worse than a smaller one that says so.
+ */
+const PAID_ACCOUNT_LIMIT = 500;
+
export interface WeeklyRow {
weekStart: Date;
visitors: number;
@@ -72,6 +82,9 @@ export interface Scoreboard {
channels: ChannelRow[];
channelWindowDays: number;
paidAccounts: PaidAccountRow[];
+ /** True when there are more paid accounts than the table shows. */
+ paidAccountsTruncated: boolean;
+ paidAccountLimit: number;
atRisk: PaidAccountRow[];
currentActivePaid: number | null;
currentMrrCents: number | null;
@@ -240,6 +253,7 @@ export async function getScoreboard(options?: { weeks?: number }): Promise ({
+ // One row over the limit was fetched purely to tell "exactly full" from "cut off".
+ const paidAccountsTruncated = paidAccounts.length > PAID_ACCOUNT_LIMIT;
+ const accounts: PaidAccountRow[] = paidAccounts.slice(0, PAID_ACCOUNT_LIMIT).map((row) => ({
userId: row.user_id,
name: row.name,
email: row.email,
@@ -313,6 +329,8 @@ export async function getScoreboard(options?: { weeks?: number }): Promise b.visitors - a.visitors),
channelWindowDays: CHANNEL_WINDOW_DAYS,
paidAccounts: accounts,
+ paidAccountsTruncated,
+ paidAccountLimit: PAID_ACCOUNT_LIMIT,
atRisk: accounts.filter(
(account) => !account.lastValueEventAt || account.lastValueEventAt < silentBefore
),
diff --git a/lib/analytics/signing.ts b/lib/analytics/signing.ts
new file mode 100644
index 0000000..02dcb8b
--- /dev/null
+++ b/lib/analytics/signing.ts
@@ -0,0 +1,115 @@
+// Signing for the two acquisition cookies.
+//
+// httpOnly keeps JavaScript out of these cookies. It does nothing about curl,
+// and both cookies are read straight into database columns, so without a
+// signature the anonymous id is simply a string the caller picked. Picking one
+// is enough to write a first-touch row for a visitor who never existed, or to
+// claim another visitor's events at signup, since the backfill matches on the
+// id alone.
+//
+// Web Crypto rather than node:crypto: this is imported by the proxy, which runs
+// on the edge, and by the pages that read the cookies back, which run in Node.
+// Both have crypto.subtle; only Node has createHmac.
+
+import { logWarn } from '@/lib/logger';
+
+const SEPARATOR = '.';
+
+/**
+ * 132 bits of an HMAC-SHA256, base64url. Truncating a MAC is standard practice
+ * and keeps a cookie that rides on every request small.
+ */
+const SIGNATURE_LENGTH = 22;
+
+let cachedSecret: string | null = null;
+let cachedKey: Promise | null = null;
+let warnedAboutMissingSecret = false;
+
+function readSecret(): string | null {
+ const secret = process.env.AUTH_SECRET?.trim() || process.env.NEXTAUTH_SECRET?.trim();
+ if (secret) return secret;
+
+ // Not thrown. The proxy runs on every request and the pages render for every
+ // visitor; failing those to protect a funnel chart would be the wrong trade.
+ // Analytics simply records nothing, which is visible on /admin/growth the same
+ // day, and it is announced once per process rather than per request.
+ if (!warnedAboutMissingSecret) {
+ warnedAboutMissingSecret = true;
+ logWarn(
+ 'AUTH_SECRET (or NEXTAUTH_SECRET) is not set, so acquisition cookies cannot be ' +
+ 'signed. Nothing will be recorded while it is missing.'
+ );
+ }
+ return null;
+}
+
+function getKey(secret: string): Promise {
+ if (!cachedKey || cachedSecret !== secret) {
+ cachedSecret = secret;
+ cachedKey = crypto.subtle.importKey(
+ 'raw',
+ new TextEncoder().encode(secret),
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign']
+ );
+ }
+ return cachedKey;
+}
+
+function toBase64Url(buffer: ArrayBuffer): string {
+ let binary = '';
+ for (const byte of new Uint8Array(buffer)) {
+ binary += String.fromCharCode(byte);
+ }
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+async function macOf(value: string, secret: string): Promise {
+ const signature = await crypto.subtle.sign(
+ 'HMAC',
+ await getKey(secret),
+ new TextEncoder().encode(value)
+ );
+ return toBase64Url(signature).slice(0, SIGNATURE_LENGTH);
+}
+
+/** Constant time, so a forged cookie learns nothing from how long it took to reject. */
+function equals(a: string, b: string): boolean {
+ if (a.length !== b.length) return false;
+ let difference = 0;
+ for (let index = 0; index < a.length; index += 1) {
+ difference |= a.charCodeAt(index) ^ b.charCodeAt(index);
+ }
+ return difference === 0;
+}
+
+/**
+ * `.`.
+ *
+ * The MAC goes first and is fixed-length, so the split is a slice at a known
+ * offset rather than a search for a separator that a future payload might
+ * happen to contain.
+ *
+ * Returns null when there is no secret to sign with, which the callers treat as
+ * "set no cookie".
+ */
+export async function signCookieValue(value: string): Promise {
+ const secret = readSecret();
+ if (!secret) return null;
+ return `${await macOf(value, secret)}${SEPARATOR}${value}`;
+}
+
+/** The signed value back, or null if it was absent, truncated, or edited. */
+export async function unsignCookieValue(signed: string | null | undefined): Promise {
+ if (typeof signed !== 'string' || signed.length <= SIGNATURE_LENGTH + 1) return null;
+
+ const secret = readSecret();
+ if (!secret) return null;
+
+ if (signed[SIGNATURE_LENGTH] !== SEPARATOR) return null;
+ const mac = signed.slice(0, SIGNATURE_LENGTH);
+ const value = signed.slice(SIGNATURE_LENGTH + 1);
+
+ return equals(mac, await macOf(value, secret)) ? value : null;
+}
diff --git a/lib/analytics/signup.ts b/lib/analytics/signup.ts
index 142fd77..cbcc96c 100644
--- a/lib/analytics/signup.ts
+++ b/lib/analytics/signup.ts
@@ -4,11 +4,9 @@
// 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 { NO_VISITOR, 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.
*
@@ -20,8 +18,8 @@ const NO_VISITOR: VisitorContext = { anonymousId: null, firstTouch: null };
export async function readVisitorContextFromHeaders(): Promise {
if (!isProductAnalyticsEnabled()) return NO_VISITOR;
try {
- const { cookies } = await import('next/headers');
- return readVisitorContext(await cookies());
+ const { cookies, headers } = await import('next/headers');
+ return await readVisitorContext(await cookies(), await headers());
} catch {
return NO_VISITOR;
}
diff --git a/lib/analytics/visitor.ts b/lib/analytics/visitor.ts
index 734ec77..74f2fc2 100644
--- a/lib/analytics/visitor.ts
+++ b/lib/analytics/visitor.ts
@@ -8,17 +8,30 @@
// a purity argument. Blocking rates differ by channel, and an undercounted
// denominator would make GitHub and Hacker News traffic look like it converts
// better than it does.
+//
+// Recording server-side also means an anonymous request can write rows, so the
+// three filters that decide whether a request counts live here rather than only
+// in the proxy: the signature on the cookie, the bot and prefetch checks, and a
+// per-client ceiling. In the proxy they only govern which cookies get issued,
+// which is not the same thing as which rows get written.
import type { AnalyticsEventName } from '@prisma/client';
import {
ANONYMOUS_ID_COOKIE,
FIRST_TOUCH_COOKIE,
- decodeFirstTouch,
- isValidAnonymousId,
+ readAnonymousIdCookie,
+ readFirstTouchCookie,
type FirstTouch,
} from '@/lib/analytics/cookies';
+import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots';
import { dailyEventKey, recordEvent, recordFirstTouch } from '@/lib/analytics/record';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
+import {
+ RATE_LIMIT_CONFIGS,
+ checkRateLimit,
+ getClientIpFromHeaders,
+ isClientIpTrustworthy,
+} from '@/lib/rate-limit';
/** Both `cookies()` from next/headers and `request.cookies` satisfy this. */
export interface AnalyticsCookieReader {
@@ -28,16 +41,71 @@ export interface AnalyticsCookieReader {
export interface VisitorContext {
anonymousId: string | null;
firstTouch: FirstTouch | null;
+ /** Carried so the ceiling below can be applied after the response, not during it. */
+ clientIp: string | null;
}
-export function readVisitorContext(store: AnalyticsCookieReader): VisitorContext {
- const rawId = store.get(ANONYMOUS_ID_COOKIE)?.value;
+export const NO_VISITOR: VisitorContext = { anonymousId: null, firstTouch: null, clientIp: null };
+
+/**
+ * The visitor behind a set of cookies, or an empty context.
+ *
+ * Both cookies are verified, so an id that reaches a database column is one this
+ * deployment issued. A forged one is not repaired or partially trusted, it is
+ * simply not a visitor.
+ */
+export async function readVisitorContext(
+ store: AnalyticsCookieReader,
+ headers?: Headers
+): Promise {
+ if (!isProductAnalyticsEnabled()) return NO_VISITOR;
+
+ const anonymousId = await readAnonymousIdCookie(store.get(ANONYMOUS_ID_COOKIE)?.value);
+ if (!anonymousId) return NO_VISITOR;
+
return {
- anonymousId: isValidAnonymousId(rawId) ? rawId : null,
- firstTouch: decodeFirstTouch(store.get(FIRST_TOUCH_COOKIE)?.value),
+ anonymousId,
+ firstTouch: await readFirstTouchCookie(store.get(FIRST_TOUCH_COOKIE)?.value),
+ clientIp: headers ? getClientIpFromHeaders(headers) : null,
};
}
+/**
+ * The visitor behind a page render.
+ *
+ * Applies the same two header checks the proxy does, because they mean different
+ * things in the two places. In the proxy they decide who gets a cookie; here they
+ * decide what counts. A returning visitor already holds a cookie, so without this
+ * Next prefetching /register as a CTA scrolls into view would record a signup
+ * start for a page nobody opened.
+ */
+export async function readPageVisitor(): Promise {
+ if (!isProductAnalyticsEnabled()) return NO_VISITOR;
+
+ const { cookies, headers } = await import('next/headers');
+ const requestHeaders = await headers();
+ if (!isCountableDocumentRequest(requestHeaders)) return NO_VISITOR;
+ if (isLikelyBot(requestHeaders.get('user-agent'))) return NO_VISITOR;
+
+ return readVisitorContext(await cookies(), requestHeaders);
+}
+
+/**
+ * The visitor behind an API request.
+ *
+ * No document check: a beacon is `sec-fetch-dest: empty` by definition, and the
+ * routes that call this are reached by a form submission rather than by a
+ * navigation.
+ */
+export async function readRequestVisitor(request: {
+ cookies: AnalyticsCookieReader;
+ headers: Headers;
+}): Promise {
+ if (!isProductAnalyticsEnabled()) return NO_VISITOR;
+ if (isLikelyBot(request.headers.get('user-agent'))) return NO_VISITOR;
+ return readVisitorContext(request.cookies, request.headers);
+}
+
const DIRECT_TOUCH: FirstTouch = {
channel: 'DIRECT',
utmSource: null,
@@ -47,6 +115,24 @@ const DIRECT_TOUCH: FirstTouch = {
landingPath: '/',
};
+/**
+ * A ceiling on how many visitors one client can invent per hour.
+ *
+ * A fresh signed cookie is one request away: drop the cookie, ask for the
+ * landing page again, and the proxy mints another id. The signature stops a
+ * caller from choosing an id, and this stops them from collecting an unbounded
+ * number of real ones. Skipped when the client IP is not real, where the bucket
+ * would be shared by everybody and would throttle the site rather than the
+ * flood.
+ */
+async function withinVisitorCeiling(clientIp: string | null): Promise {
+ if (!clientIp || !isClientIpTrustworthy()) return true;
+
+ const config = RATE_LIMIT_CONFIGS['analytics-visitor'];
+ const result = await checkRateLimit(clientIp, 'analytics-visitor', config);
+ return result.allowed;
+}
+
/**
* Records an event for a visitor with no account, once per visitor per UTC day.
*
@@ -59,6 +145,7 @@ export async function recordVisitorEvent(
): Promise {
if (!isProductAnalyticsEnabled()) return;
if (!visitor.anonymousId) return;
+ if (!(await withinVisitorCeiling(visitor.clientIp))) return;
const touch = visitor.firstTouch ?? DIRECT_TOUCH;
diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts
index 011b767..b8e7bda 100644
--- a/lib/rate-limit.ts
+++ b/lib/rate-limit.ts
@@ -90,8 +90,11 @@ export const RATE_LIMIT_CONFIGS: Record = {
'verify-email': { windowMs: 15 * 60 * 1000, maxRequests: 20 }, // 20 per 15 min (clicked link)
'resend-verification': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
- // Onboarding — one-time action, very strict
+ // Onboarding — one-time action, very strict. Both are keyed by user id, not IP:
+ // an office behind one address must not be able to lock its colleagues out of
+ // finishing onboarding.
'onboarding-complete': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
+ 'onboarding-source': { windowMs: 60 * 60 * 1000, maxRequests: 5 }, // 5 per hour
// Member management
'invite-member': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
@@ -108,6 +111,13 @@ export const RATE_LIMIT_CONFIGS: Record = {
// Analytics beacon — anonymous and public, so bound it per IP
'analytics-beacon': { windowMs: 60 * 60 * 1000, maxRequests: 30 }, // 30 per hour
+ // Anonymous visitor events recorded server-side from the landing pages. Bounds
+ // a flood that would otherwise write two rows per request forever, and is
+ // deliberately generous: these are the denominator of every rate on the
+ // scoreboard, so a limit that bites real traffic costs more than the flood it
+ // stops. Only applied when the client IP is real — see isClientIpTrustworthy.
+ 'analytics-visitor': { windowMs: 60 * 60 * 1000, maxRequests: 240 }, // 240 per hour
+
// General reads — generous
api: { windowMs: 60 * 1000, maxRequests: 100 }, // 100 per minute
};
@@ -264,6 +274,20 @@ export function getClientIpFromHeaders(headers: Headers): string {
return '127.0.0.1';
}
+/**
+ * Whether {@link getClientIp} resolves to the caller rather than to 127.0.0.1.
+ *
+ * Without TRUSTED_PROXY_MODE every request shares one bucket. That is a usable
+ * global brake on an endpoint nobody hits in a loop, and useless on a landing
+ * page: the bucket would empty on real traffic long before it emptied on an
+ * attacker, and the counting this whole subsystem exists for would stop. Callers
+ * that only make sense per-client check this first.
+ */
+export function isClientIpTrustworthy(): boolean {
+ const mode = process.env.TRUSTED_PROXY_MODE?.trim().toLowerCase();
+ return mode === 'cloudflare' || mode === 'nginx';
+}
+
/**
* Create rate limit headers for response
*/
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 0f6f42b..b1ccefb 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -750,9 +750,10 @@ model VideoUploadSession {
// 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.
+// Referrers are reduced to a host and landing pages to a path, both without
+// query strings, so a shared link with a name or token in it cannot leak in
+// here. The one free-text column is user_acquisitions.self_reported_note, which
+// holds up to 200 characters the account typed into the onboarding question.
enum AcquisitionChannel {
DIRECT
diff --git a/proxy.ts b/proxy.ts
index be1a726..a99d5a0 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -10,17 +10,22 @@ import {
ANONYMOUS_ID_COOKIE,
ANONYMOUS_ID_MAX_AGE_SECONDS,
FIRST_TOUCH_COOKIE,
- encodeFirstTouch,
generateAnonymousId,
- isValidAnonymousId,
+ readAnonymousIdCookie,
+ signAnonymousId,
+ signFirstTouch,
} from '@/lib/analytics/cookies';
import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots';
import { isProductAnalyticsEnabled } from '@/lib/feature-flags';
+import { getPublicOrigin } from '@/lib/request-origin';
// Runs on the edge, so nothing here touches the database. It only decides who a
-// visitor is and what brought them, then hands both downstream as cookies. The
-// rows are written by the pages, which run in Node.
-function applyAcquisitionCookies(request: NextRequest, response: NextResponse): void {
+// visitor is and what brought them, then hands both downstream as signed
+// cookies. The rows are written by the pages, which run in Node.
+async function applyAcquisitionCookies(
+ request: NextRequest,
+ response: NextResponse
+): Promise {
if (!isProductAnalyticsEnabled()) return;
if (!isCountableDocumentRequest(request.headers)) return;
if (isLikelyBot(request.headers.get('user-agent'))) return;
@@ -28,19 +33,24 @@ function applyAcquisitionCookies(request: NextRequest, response: NextResponse):
const cookieOptions = {
httpOnly: true,
sameSite: 'lax' as const,
- secure: request.nextUrl.protocol === 'https:',
+ // Not `request.nextUrl.protocol`. Behind a TLS-terminating reverse proxy,
+ // which is the deployment shape the README documents, that is the
+ // container-internal `http://localhost:3000` and the flag would silently
+ // come off in exactly the setup that needs it.
+ secure: getPublicOrigin(request).startsWith('https:'),
path: '/',
maxAge: ANONYMOUS_ID_MAX_AGE_SECONDS,
};
- const existingId = request.cookies.get(ANONYMOUS_ID_COOKIE)?.value;
- if (!isValidAnonymousId(existingId)) {
- const anonymousId = generateAnonymousId();
+ const existingId = await readAnonymousIdCookie(request.cookies.get(ANONYMOUS_ID_COOKIE)?.value);
+ if (!existingId) {
+ const signedId = await signAnonymousId(generateAnonymousId());
+ if (!signedId) return;
// Set on the request as well as the response: without this the page rendering
// *this* request cannot see the id, and the first landing view of every new
// visitor, the one carrying the campaign tags, goes unrecorded.
- request.cookies.set(ANONYMOUS_ID_COOKIE, anonymousId);
- response.cookies.set(ANONYMOUS_ID_COOKIE, anonymousId, cookieOptions);
+ request.cookies.set(ANONYMOUS_ID_COOKIE, signedId);
+ response.cookies.set(ANONYMOUS_ID_COOKIE, signedId, cookieOptions);
}
if (request.cookies.get(FIRST_TOUCH_COOKIE)) return;
@@ -53,7 +63,7 @@ function applyAcquisitionCookies(request: NextRequest, response: NextResponse):
const utmSource = sanitizeTag(params.get('utm_source'));
const utmMedium = sanitizeTag(params.get('utm_medium'));
- const firstTouch = encodeFirstTouch({
+ const firstTouch = await signFirstTouch({
channel: classifyChannel({ utmSource, utmMedium, referrerHost }),
utmSource,
utmMedium,
@@ -61,15 +71,16 @@ function applyAcquisitionCookies(request: NextRequest, response: NextResponse):
referrerHost,
landingPath: sanitizeLandingPath(request.nextUrl.pathname),
});
+ if (!firstTouch) return;
request.cookies.set(FIRST_TOUCH_COOKIE, firstTouch);
response.cookies.set(FIRST_TOUCH_COOKIE, firstTouch, cookieOptions);
}
-export function proxy(request: NextRequest) {
+export async function proxy(request: NextRequest) {
const response = NextResponse.next({ request });
response.headers.set('Content-Security-Policy', buildContentSecurityPolicy());
- applyAcquisitionCookies(request, response);
+ await applyAcquisitionCookies(request, response);
return response;
}
diff --git a/tests/api/analytics-events.test.ts b/tests/api/analytics-events.test.ts
index 5cfced9..c14f10c 100644
--- a/tests/api/analytics-events.test.ts
+++ b/tests/api/analytics-events.test.ts
@@ -13,7 +13,7 @@ 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 { signAnonymousId, signFirstTouch, type FirstTouch } from '@/lib/analytics/cookies';
import { apiRequest, callRoute } from '../helpers/request';
import { signedOut } from '../helpers/session';
import { createUser } from '../factories';
@@ -21,6 +21,9 @@ import { createUser } from '../factories';
const ANON_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
const INVITE_CODE = 'test-invite';
const ORIGIN = 'http://localhost:3000';
+const SECRET = 'analytics-test-secret';
+const BROWSER_UA =
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36';
const GITHUB_TOUCH: FirstTouch = {
channel: 'GITHUB',
@@ -31,23 +34,30 @@ const GITHUB_TOUCH: FirstTouch = {
landingPath: '/',
};
-function visitorCookies(anonymousId = ANON_ID, touch: FirstTouch = GITHUB_TOUCH) {
- return { of_aid: anonymousId, of_ft: encodeFirstTouch(touch) };
+/** What the proxy would have set. Signed, because nothing downstream trusts anything else. */
+async function visitorCookies(anonymousId = ANON_ID, touch: FirstTouch = GITHUB_TOUCH) {
+ return {
+ of_aid: (await signAnonymousId(anonymousId)) ?? '',
+ of_ft: (await signFirstTouch(touch)) ?? '',
+ };
}
-function beaconRequest(options?: {
+async function beaconRequest(options?: {
name?: string;
origin?: string | null;
+ userAgent?: string | null;
cookies?: Record;
}) {
const headers: Record = {};
const origin = options?.origin === undefined ? ORIGIN : options.origin;
if (origin) headers.origin = origin;
+ const userAgent = options?.userAgent === undefined ? BROWSER_UA : options.userAgent;
+ if (userAgent) headers['user-agent'] = userAgent;
return apiRequest('/api/events', {
body: { name: options?.name ?? 'cta_clicked' },
headers,
- cookies: options?.cookies ?? visitorCookies(),
+ cookies: options?.cookies ?? (await visitorCookies()),
});
}
@@ -59,6 +69,7 @@ async function eventNames(): Promise {
beforeEach(() => {
signedOut();
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
+ vi.stubEnv('NEXTAUTH_SECRET', SECRET);
});
afterEach(() => {
@@ -67,7 +78,7 @@ afterEach(() => {
describe('POST /api/events', () => {
it('records a CTA click and the first touch behind it', async () => {
- const response = await callRoute(beacon, beaconRequest());
+ const response = await callRoute(beacon, await beaconRequest());
expect(response.status).toBe(204);
@@ -91,29 +102,33 @@ describe('POST /api/events', () => {
});
it('records one event however many times the same visitor clicks', async () => {
- await callRoute(beacon, beaconRequest());
- await callRoute(beacon, beaconRequest());
- await callRoute(beacon, beaconRequest());
+ await callRoute(beacon, await beaconRequest());
+ await callRoute(beacon, await beaconRequest());
+ await callRoute(beacon, await beaconRequest());
expect(await db.analyticsEvent.count()).toBe(1);
});
it('counts two different visitors separately', async () => {
- await callRoute(beacon, beaconRequest());
+ await callRoute(beacon, await beaconRequest());
await callRoute(
beacon,
- beaconRequest({ cookies: visitorCookies('f0e1d2c3b4a596877869504132231415') })
+ await beaconRequest({ cookies: await visitorCookies('f0e1d2c3b4a596877869504132231415') })
);
expect(await db.analyticsEvent.count()).toBe(2);
});
it('never keeps the first touch of a visitor who came back through another link', async () => {
- await callRoute(beacon, beaconRequest());
+ await callRoute(beacon, await beaconRequest());
await callRoute(
beacon,
- beaconRequest({
- cookies: visitorCookies(ANON_ID, { ...GITHUB_TOUCH, channel: 'GOOGLE', utmSource: null }),
+ await beaconRequest({
+ cookies: await visitorCookies(ANON_ID, {
+ ...GITHUB_TOUCH,
+ channel: 'GOOGLE',
+ utmSource: null,
+ }),
})
);
@@ -126,7 +141,7 @@ describe('POST /api/events', () => {
// 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 }));
+ const response = await callRoute(beacon, await beaconRequest({ name }));
expect(response.status, name).toBe(204);
}
@@ -134,23 +149,59 @@ describe('POST /api/events', () => {
});
it('ignores a cross-origin caller', async () => {
- const response = await callRoute(beacon, beaconRequest({ origin: 'https://evil.example' }));
+ const response = await callRoute(
+ beacon,
+ await beaconRequest({ origin: 'https://evil.example' })
+ );
expect(response.status).toBe(204);
expect(await db.analyticsEvent.count()).toBe(0);
});
it('ignores a caller with no anonymous id cookie', async () => {
- await callRoute(beacon, beaconRequest({ cookies: {} }));
+ await callRoute(beacon, await beaconRequest({ cookies: {} }));
expect(await db.analyticsEvent.count()).toBe(0);
expect(await db.acquisitionTouch.count()).toBe(0);
});
+ it('ignores a hand-written cookie, whatever channel it claims', async () => {
+ // httpOnly stops JavaScript, not curl. Without the signature this is a
+ // visitor of the caller's choosing, filed under a channel of their choosing,
+ // and every row on the scoreboard is theirs to write.
+ await callRoute(
+ beacon,
+ await beaconRequest({
+ cookies: {
+ of_aid: 'deadbeefdeadbeefdeadbeefdeadbeef',
+ of_ft: encodeURIComponent(JSON.stringify({ c: 'GITHUB', p: '/' })),
+ },
+ })
+ );
+
+ expect(await db.analyticsEvent.count()).toBe(0);
+ expect(await db.acquisitionTouch.count()).toBe(0);
+ });
+
+ it('ignores a cookie signed by another deployment', async () => {
+ const cookies = await visitorCookies();
+ vi.stubEnv('NEXTAUTH_SECRET', 'some-other-secret');
+
+ await callRoute(beacon, await beaconRequest({ cookies }));
+
+ expect(await db.analyticsEvent.count()).toBe(0);
+ });
+
+ it('ignores a script that sends no user agent', async () => {
+ await callRoute(beacon, await beaconRequest({ userAgent: null }));
+
+ expect(await db.analyticsEvent.count()).toBe(0);
+ });
+
it('writes nothing at all when the flag is off', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
- const response = await callRoute(beacon, beaconRequest());
+ const response = await callRoute(beacon, await beaconRequest());
expect(response.status).toBe(204);
expect(await db.analyticsEvent.count()).toBe(0);
@@ -169,7 +220,8 @@ describe('signup attribution', () => {
password: 'correct horse battery',
inviteCode: INVITE_CODE,
},
- cookies: visitorCookies(),
+ headers: { 'user-agent': BROWSER_UA },
+ cookies: await visitorCookies(),
})
);
}
@@ -197,7 +249,7 @@ describe('signup attribution', () => {
});
it('claims the events the visitor produced before they had an account', async () => {
- await callRoute(beacon, beaconRequest());
+ await callRoute(beacon, await beaconRequest());
await registerWithCookies('backfilled@example.com');
const user = await db.user.findUniqueOrThrow({ where: { email: 'backfilled@example.com' } });
@@ -213,11 +265,11 @@ describe('signup attribution', () => {
await recordSignupCompleted({
userId: user.id,
- visitor: { anonymousId: ANON_ID, firstTouch: GITHUB_TOUCH },
+ visitor: { anonymousId: ANON_ID, firstTouch: GITHUB_TOUCH, clientIp: null },
});
await recordSignupCompleted({
userId: user.id,
- visitor: { anonymousId: ANON_ID, firstTouch: GITHUB_TOUCH },
+ visitor: { anonymousId: ANON_ID, firstTouch: GITHUB_TOUCH, clientIp: null },
});
expect(await db.analyticsEvent.count({ where: { name: 'SIGNUP_COMPLETED' } })).toBe(1);
diff --git a/tests/unit/lib/analytics-channel.test.ts b/tests/unit/lib/analytics-channel.test.ts
index 3a197f3..c44a175 100644
--- a/tests/unit/lib/analytics-channel.test.ts
+++ b/tests/unit/lib/analytics-channel.test.ts
@@ -76,6 +76,20 @@ describe('sanitizeLandingPath', () => {
expect(sanitizeLandingPath('https://open-frame.net/x')).toBe('/');
expect(sanitizeLandingPath(null)).toBe('/');
});
+
+ it('keeps what a real route can carry', () => {
+ expect(sanitizeLandingPath('/vs/frame.io')).toBe('/vs/frame.io');
+ expect(sanitizeLandingPath('/watch/cm4x-01_a')).toBe('/watch/cm4x-01_a');
+ expect(sanitizeLandingPath('/blog/%C3%BCr%C3%BCn')).toBe('/blog/%C3%BCr%C3%BCn');
+ });
+
+ it('drops a path that could only have come from a hand-written cookie', () => {
+ // The proxy feeds this a real pathname. The cookie reader feeds it whatever
+ // the cookie said, and that value ends up in a database column.
+ expect(sanitizeLandingPath('/')).toBe('/');
+ expect(sanitizeLandingPath('/ok\nX-Injected: 1')).toBe('/');
+ expect(sanitizeLandingPath('/a b')).toBe('/');
+ });
});
describe('classifyChannel', () => {
diff --git a/tests/unit/lib/analytics-cookies.test.ts b/tests/unit/lib/analytics-cookies.test.ts
index 82adb41..e2e73f0 100644
--- a/tests/unit/lib/analytics-cookies.test.ts
+++ b/tests/unit/lib/analytics-cookies.test.ts
@@ -1,14 +1,31 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
decodeFirstTouch,
encodeFirstTouch,
generateAnonymousId,
isAcquisitionChannel,
isValidAnonymousId,
+ readAnonymousIdCookie,
+ readFirstTouchCookie,
+ signAnonymousId,
+ signFirstTouch,
type FirstTouch,
} from '@/lib/analytics/cookies';
import { isCountableDocumentRequest, isLikelyBot } from '@/lib/analytics/bots';
+beforeEach(() => {
+ vi.stubEnv('NEXTAUTH_SECRET', 'cookie-test-secret');
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+/** The cookie body a forged value would have to carry, in the wire form the reader expects. */
+function body(payload: unknown): string {
+ return btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
const TOUCH: FirstTouch = {
channel: 'GITHUB',
utmSource: 'github',
@@ -36,23 +53,27 @@ describe('first touch cookie', () => {
});
it('rejects a hand-edited cookie carrying an unknown channel', () => {
- const forged = encodeURIComponent(JSON.stringify({ c: 'INVESTOR_DEMO', p: '/' }));
- expect(decodeFirstTouch(forged)).toBeNull();
+ expect(decodeFirstTouch(body({ c: 'INVESTOR_DEMO', p: '/' }))).toBeNull();
});
it('re-sanitizes fields rather than trusting the cookie', () => {
- const forged = encodeURIComponent(
- JSON.stringify({ c: 'DIRECT', p: '/x', s: '', r: 'not a host' })
+ const decoded = decodeFirstTouch(
+ body({ c: 'DIRECT', p: '/x', s: '', r: 'not a host' })
);
- const decoded = decodeFirstTouch(forged);
expect(decoded?.utmSource).toBeNull();
expect(decoded?.referrerHost).toBeNull();
});
+ it('re-sanitizes a landing path that could only have been hand-written', () => {
+ expect(decodeFirstTouch(body({ c: 'DIRECT', p: '/ ' }))?.landingPath).toBe(
+ '/'
+ );
+ });
+
it('returns null for garbage and for an absent cookie', () => {
- expect(decodeFirstTouch('%%%not-json%%%')).toBeNull();
+ expect(decodeFirstTouch('%%%not-base64%%%')).toBeNull();
expect(decodeFirstTouch(null)).toBeNull();
- expect(decodeFirstTouch(encodeURIComponent(JSON.stringify(['DIRECT'])))).toBeNull();
+ expect(decodeFirstTouch(body(['DIRECT']))).toBeNull();
});
});
@@ -73,6 +94,71 @@ describe('anonymous id', () => {
});
});
+// Everything above tests the unsigned inner layer. Nothing outside the module
+// uses it: a cookie is only a visitor once the signature says this deployment
+// issued it, which is what stops a caller from inventing one with curl.
+describe('signed cookies', () => {
+ const TOUCH_TO_SIGN: FirstTouch = {
+ channel: 'YOUTUBE',
+ utmSource: 'yt',
+ utmMedium: null,
+ utmCampaign: null,
+ referrerHost: 'youtube.com',
+ landingPath: '/',
+ };
+
+ it('round-trips an id and a first touch', async () => {
+ const id = generateAnonymousId();
+ expect(await readAnonymousIdCookie(await signAnonymousId(id))).toBe(id);
+ expect(await readFirstTouchCookie(await signFirstTouch(TOUCH_TO_SIGN))).toEqual(TOUCH_TO_SIGN);
+ });
+
+ it('rejects a well-formed id that carries no signature', async () => {
+ expect(await readAnonymousIdCookie('a1b2c3d4e5f60718293a4b5c6d7e8f90')).toBeNull();
+ });
+
+ it('rejects a value whose body was edited under a valid signature', async () => {
+ const signed = (await signAnonymousId(generateAnonymousId())) ?? '';
+ const [mac] = signed.split('.');
+
+ expect(await readAnonymousIdCookie(`${mac}.a1b2c3d4e5f60718293a4b5c6d7e8f90`)).toBeNull();
+ });
+
+ it('rejects a first touch re-signed to name another channel', async () => {
+ const forgedBody = encodeFirstTouch({ ...TOUCH_TO_SIGN, channel: 'GITHUB' });
+ const signed = (await signFirstTouch(TOUCH_TO_SIGN)) ?? '';
+ const [mac] = signed.split('.');
+
+ expect(await readFirstTouchCookie(`${mac}.${forgedBody}`)).toBeNull();
+ });
+
+ it('rejects a cookie signed with another deployment key', async () => {
+ const signed = await signAnonymousId(generateAnonymousId());
+
+ vi.stubEnv('NEXTAUTH_SECRET', 'someone-elses-secret');
+
+ expect(await readAnonymousIdCookie(signed)).toBeNull();
+ });
+
+ it('signs nothing and accepts nothing when there is no secret', async () => {
+ const signed = await signAnonymousId(generateAnonymousId());
+
+ vi.stubEnv('NEXTAUTH_SECRET', undefined);
+ vi.stubEnv('AUTH_SECRET', undefined);
+
+ expect(await signAnonymousId(generateAnonymousId())).toBeNull();
+ expect(await readAnonymousIdCookie(signed)).toBeNull();
+ });
+
+ it('rejects the empty, the truncated and the separator-less', async () => {
+ expect(await readAnonymousIdCookie('')).toBeNull();
+ expect(await readAnonymousIdCookie(undefined)).toBeNull();
+ expect(await readAnonymousIdCookie('.')).toBeNull();
+ expect(await readAnonymousIdCookie('a'.repeat(22))).toBeNull();
+ expect(await readFirstTouchCookie('not-signed-at-all')).toBeNull();
+ });
+});
+
describe('isAcquisitionChannel', () => {
it('accepts the nine buckets and nothing else', () => {
expect(isAcquisitionChannel('REVIEW_LINK')).toBe(true);
diff --git a/tests/unit/proxy.test.ts b/tests/unit/proxy.test.ts
index b0320ef..a1860dc 100644
--- a/tests/unit/proxy.test.ts
+++ b/tests/unit/proxy.test.ts
@@ -1,15 +1,22 @@
// 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
+// Two load-bearing details below. The id is written to the *request* as well as
+// the response, because a cookie set only on the response is invisible to the
// page rendering that same request, so the very first landing view, the one
-// carrying the campaign tags that brought the visitor, would go unrecorded.
+// carrying the campaign tags that brought the visitor, would go unrecorded. And
+// both cookies are signed, because they are read straight into database columns
+// and httpOnly stops JavaScript, not curl.
-import { describe, it, expect, afterEach, vi } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { NextRequest } from 'next/server';
import { proxy } from '@/proxy';
-import { ANONYMOUS_ID_COOKIE, FIRST_TOUCH_COOKIE, decodeFirstTouch } from '@/lib/analytics/cookies';
+import {
+ ANONYMOUS_ID_COOKIE,
+ FIRST_TOUCH_COOKIE,
+ readAnonymousIdCookie,
+ readFirstTouchCookie,
+} from '@/lib/analytics/cookies';
const BROWSER_UA =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126 Safari/537.36';
@@ -30,43 +37,51 @@ function documentRequest(
return new NextRequest(new URL(url), { headers });
}
+beforeEach(() => {
+ vi.stubEnv('NEXTAUTH_SECRET', 'proxy-test-secret');
+ // getPublicOrigin prefers a configured origin over the request URL, so the
+ // tests that care about the request URL have to start from neither being set.
+ vi.stubEnv('NEXTAUTH_URL', undefined);
+ vi.stubEnv('NEXT_PUBLIC_APP_URL', undefined);
+});
+
afterEach(() => {
vi.unstubAllEnvs();
});
describe('proxy', () => {
- it('always sets the content security policy', () => {
+ it('always sets the content security policy', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
- const response = proxy(documentRequest('https://open-frame.net/'));
+ const response = await proxy(documentRequest('https://open-frame.net/'));
expect(response.headers.get('Content-Security-Policy')).toContain("default-src 'self'");
});
- it('sets no acquisition cookie at all when the flag is off', () => {
+ it('sets no acquisition cookie at all when the flag is off', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'false');
- const response = proxy(documentRequest('https://open-frame.net/?utm_source=github'));
+ const response = await proxy(documentRequest('https://open-frame.net/?utm_source=github'));
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
expect(response.cookies.get(FIRST_TOUCH_COOKIE)).toBeUndefined();
});
- it('gives a new visitor an id and stores what brought them', () => {
+ it('gives a new visitor an id and stores what brought them', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
const request = documentRequest(
'https://open-frame.net/?utm_source=youtube&utm_medium=video&utm_campaign=launch'
);
- const response = proxy(request);
+ const response = await 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 cookie = response.cookies.get(ANONYMOUS_ID_COOKIE);
+ expect(await readAnonymousIdCookie(cookie?.value)).toMatch(/^[a-z0-9]{32}$/);
+ expect(cookie?.httpOnly).toBe(true);
+ expect(cookie?.sameSite).toBe('lax');
+ expect(cookie?.secure).toBe(true);
- const touch = decodeFirstTouch(response.cookies.get(FIRST_TOUCH_COOKIE)?.value);
+ const touch = await readFirstTouchCookie(response.cookies.get(FIRST_TOUCH_COOKIE)?.value);
expect(touch).toEqual({
channel: 'YOUTUBE',
utmSource: 'youtube',
@@ -77,33 +92,38 @@ describe('proxy', () => {
});
// 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(ANONYMOUS_ID_COOKIE)?.value).toBe(cookie?.value);
expect(request.cookies.get(FIRST_TOUCH_COOKIE)?.value).toBeDefined();
});
- it('classifies a visit that only carries a referrer', () => {
+ it('classifies a visit that only carries a referrer', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
- const response = proxy(
+ const response = await 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({
+ expect(
+ await readFirstTouchCookie(response.cookies.get(FIRST_TOUCH_COOKIE)?.value)
+ ).toMatchObject({
channel: 'GITHUB',
referrerHost: 'github.com',
landingPath: '/vs/frameio',
});
});
- it('does not overwrite the first touch of a returning visitor', () => {
+ it('does not overwrite the first touch of a returning visitor', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
- const existingId = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
+ const issued = await proxy(documentRequest('https://open-frame.net/?utm_source=github'));
- const response = proxy(
+ const response = await proxy(
documentRequest('https://open-frame.net/?utm_source=google', {
- cookies: { [ANONYMOUS_ID_COOKIE]: existingId, [FIRST_TOUCH_COOKIE]: 'anything' },
+ cookies: {
+ [ANONYMOUS_ID_COOKIE]: issued.cookies.get(ANONYMOUS_ID_COOKIE)?.value ?? '',
+ [FIRST_TOUCH_COOKIE]: issued.cookies.get(FIRST_TOUCH_COOKIE)?.value ?? '',
+ },
})
);
@@ -111,22 +131,56 @@ describe('proxy', () => {
expect(response.cookies.get(FIRST_TOUCH_COOKIE)).toBeUndefined();
});
- it('replaces an id that does not look like one we issued', () => {
+ it('replaces an id it did not sign, however well formed it looks', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
- const response = proxy(
+ // The shape a real id has, chosen by the caller rather than issued here.
+ // Accepting it would let anyone mint visitors, and claim the events of one
+ // whose id they guessed.
+ const forged = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
+
+ const response = await proxy(
documentRequest('https://open-frame.net/', {
- cookies: { [ANONYMOUS_ID_COOKIE]: 'nope' },
+ cookies: { [ANONYMOUS_ID_COOKIE]: forged },
})
);
- expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.value).toMatch(/^[a-z0-9]{32}$/);
+ const issued = await readAnonymousIdCookie(response.cookies.get(ANONYMOUS_ID_COOKIE)?.value);
+ expect(issued).toMatch(/^[a-z0-9]{32}$/);
+ expect(issued).not.toBe(forged);
});
- it('ignores crawlers, so they never enter the visitor count', () => {
+ it('replaces an id signed with another deployment key', async () => {
+ vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
+ const issued = await proxy(documentRequest('https://open-frame.net/'));
+ const stolen = issued.cookies.get(ANONYMOUS_ID_COOKIE)?.value ?? '';
+
+ vi.stubEnv('NEXTAUTH_SECRET', 'a-different-secret');
+ const response = await proxy(
+ documentRequest('https://open-frame.net/', {
+ cookies: { [ANONYMOUS_ID_COOKIE]: stolen },
+ })
+ );
+
+ expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.value).toBeDefined();
+ expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.value).not.toBe(stolen);
+ });
+
+ it('sets nothing when there is no secret to sign with', async () => {
+ vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
+ vi.stubEnv('NEXTAUTH_SECRET', undefined);
+ vi.stubEnv('AUTH_SECRET', undefined);
+
+ const response = await proxy(documentRequest('https://open-frame.net/'));
+
+ expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
+ expect(response.cookies.get(FIRST_TOUCH_COOKIE)).toBeUndefined();
+ });
+
+ it('ignores crawlers, so they never enter the visitor count', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
- const response = proxy(
+ const response = await proxy(
documentRequest('https://open-frame.net/', {
headers: { 'user-agent': 'Googlebot/2.1 (+http://www.google.com/bot.html)' },
})
@@ -135,15 +189,15 @@ describe('proxy', () => {
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
});
- it('ignores a prefetch and an API call', () => {
+ it('ignores a prefetch and an API call', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
- const prefetch = proxy(
+ const prefetch = await proxy(
documentRequest('https://open-frame.net/register', {
headers: { 'next-router-prefetch': '1' },
})
);
- const api = proxy(
+ const api = await proxy(
documentRequest('https://open-frame.net/api/projects', {
headers: { 'sec-fetch-dest': 'empty' },
})
@@ -153,11 +207,23 @@ describe('proxy', () => {
expect(api.cookies.get(ANONYMOUS_ID_COOKIE)).toBeUndefined();
});
- it('leaves the cookie insecure on plain http, so local development works', () => {
+ it('leaves the cookie insecure on plain http, so local development works', async () => {
vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
- const response = proxy(documentRequest('http://localhost:3000/'));
+ const response = await proxy(documentRequest('http://localhost:3000/'));
expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.secure).toBe(false);
});
+
+ it('keeps the cookie secure behind a TLS-terminating proxy', async () => {
+ vi.stubEnv('OPENFRAME_ENABLE_ANALYTICS', 'true');
+ // What a Docker deployment looks like from inside the container: the request
+ // arrived over http on an internal address, and only the configured origin
+ // knows the site is served over TLS.
+ vi.stubEnv('NEXTAUTH_URL', 'https://open-frame.net');
+
+ const response = await proxy(documentRequest('http://localhost:3000/'));
+
+ expect(response.cookies.get(ANONYMOUS_ID_COOKIE)?.secure).toBe(true);
+ });
});