Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/app/admin/__tests__/analytics.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const trackingStoreMock = vi.hoisted(() => ({
getTotals: vi.fn(),
getTopPages: vi.fn(),
getRecentEvents: vi.fn(),
getEngagement: vi.fn(),
}));

const storeMock = vi.hoisted(() => ({ storageBackend: "memory" as "redis" | "memory" }));
Expand All @@ -19,6 +20,9 @@ vi.mock("@/components/admin/AdminCharts", () => ({
TrafficChart: () => <div data-testid="traffic-chart" />,
TopPagesChart: () => <div data-testid="top-pages-chart" />,
DevicePie: () => <div data-testid="device-pie" />,
FunnelChart: ({ data }: { data: { stage: string; value: number }[] }) => (
<div data-testid="funnel-chart">{data.map((d) => `${d.stage}:${d.value}`).join(",")}</div>
),
}));

function Wrapper({ children }: { children: ReactNode }) {
Expand Down Expand Up @@ -47,6 +51,11 @@ beforeEach(() => {
trackingStoreMock.getRecentEvents.mockResolvedValue([
{ t: Date.now(), ty: "page_view", p: "/blog", d: "mobile", b: "chrome" },
]);
trackingStoreMock.getEngagement.mockResolvedValue({
browsers: { chrome: 10, safari: 4 },
scrollDepth: { 25: 8, 50: 6, 75: 4, 100: 2 },
quiz: { starts: 5, completes: 3 },
});
});

describe("admin analytics page", () => {
Expand All @@ -60,10 +69,22 @@ describe("admin analytics page", () => {
expect(screen.getByText("8")).toBeInTheDocument();
expect(screen.getByTestId("traffic-chart")).toBeInTheDocument();
expect(screen.getByTestId("top-pages-chart")).toBeInTheDocument();
expect(screen.getByTestId("device-pie")).toBeInTheDocument();
expect(screen.getAllByTestId("device-pie")).toHaveLength(2);
expect(screen.getByText("page_view")).toBeInTheDocument();
});

it("surfaces the browser split, scroll funnel and quiz funnel", async () => {
const { default: Page } = await import("@/app/admin/analytics/page");
render(await Page(), { wrapper: Wrapper });

expect(screen.getByText("Browsers (7d)")).toBeInTheDocument();
expect(screen.getByText("Scroll depth (7d)")).toBeInTheDocument();

const funnels = screen.getAllByTestId("funnel-chart").map((el) => el.textContent);
expect(funnels).toContain("25%:8,50%:6,75%:4,100%:2");
expect(funnels).toContain("Started:5,Completed:3");
});

it("shows the storage backend badge", async () => {
const { default: Page } = await import("@/app/admin/analytics/page");
const view = await Page();
Expand Down
59 changes: 56 additions & 3 deletions src/app/admin/analytics/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Badge, Card, Group, Paper, SimpleGrid, Stack, Text, Title } from '@mantine/core';
import { DevicePie, TopPagesChart, TrafficChart } from '@/components/admin/AdminCharts';
import { DevicePie, FunnelChart, TopPagesChart, TrafficChart } from '@/components/admin/AdminCharts';
import { storageBackend } from '@/lib/abuse/store';
import { getDaySeries, getRecentEvents, getTopPages, getTotals } from '@/lib/tracking-store';
import { getDaySeries, getEngagement, getRecentEvents, getTopPages, getTotals } from '@/lib/tracking-store';
import { RecentEventsTable } from './AnalyticsTables';
import { LastUpdated } from '../LastUpdated';

Expand All @@ -10,17 +10,31 @@ export const metadata = {
};

export default async function AdminAnalyticsPage() {
const [series, totals, topPages, recent] = await Promise.all([
const [series, totals, topPages, recent, engagement] = await Promise.all([
getDaySeries(14),
getTotals(7),
getTopPages(7, 8),
getRecentEvents(40),
getEngagement(7),
]);

const deviceData = Object.entries(totals.devices)
.map(([name, value]) => ({ name, value }))
.filter((d) => d.value > 0);

const browserData = Object.entries(engagement.browsers)
.map(([name, value]) => ({ name, value }))
.filter((d) => d.value > 0);

const scrollData = Object.entries(engagement.scrollDepth)
.map(([stage, value]) => ({ stage: `${stage}%`, value }))
.sort((a, b) => Number(a.stage.replace('%', '')) - Number(b.stage.replace('%', '')));

const quizData = [
{ stage: 'Started', value: engagement.quiz.starts },
{ stage: 'Completed', value: engagement.quiz.completes },
].filter((d) => d.value > 0);

const hasTraffic = series.some((d) => d.pageviews > 0 || d.uniques > 0 || d.sessions > 0);

return (
Expand Down Expand Up @@ -144,6 +158,45 @@ export default async function AdminAnalyticsPage() {
</Paper>
</SimpleGrid>

<SimpleGrid cols={{ base: 1, lg: 3 }}>
<Paper withBorder p="lg">
<Title order={4} mb="md">
Browsers (7d)
</Title>
{browserData.length === 0 ? (
<Text c="dimmed" size="sm">
No data yet.
</Text>
) : (
<DevicePie data={browserData} />
)}
</Paper>
<Paper withBorder p="lg">
<Title order={4} mb="md">
Scroll depth (7d)
</Title>
{scrollData.every((d) => d.value === 0) ? (
<Text c="dimmed" size="sm">
No scrolls recorded yet.
</Text>
) : (
<FunnelChart data={scrollData} />
)}
</Paper>
<Paper withBorder p="lg">
<Title order={4} mb="md">
DevSprint funnel (7d)
</Title>
{quizData.length === 0 ? (
<Text c="dimmed" size="sm">
No quiz sessions yet.
</Text>
) : (
<FunnelChart data={quizData} />
)}
</Paper>
</SimpleGrid>

<RecentEventsTable rows={recent} />
</Stack>
);
Expand Down
47 changes: 47 additions & 0 deletions src/app/api/analytics/event/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,25 @@ import { NextRequest } from "next/server";

const rateLimitMock = vi.hoisted(() => vi.fn(() => ({ allowed: true, retryAfter: 0 })));
const recordEventMock = vi.hoisted(() => vi.fn(async () => {}));
const loggerMock = vi.hoisted(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}));

vi.mock("@/lib/rateLimiter", () => ({
rateLimit: rateLimitMock,
}));

vi.mock("@/lib/logger", () => ({ logger: loggerMock }));

vi.mock("@/lib/tracking-store", () => ({
EVENT_TYPES_SET: new Set([
"session_start",
"page_view",
"nav_click",
"cta_click",
"scroll_depth",
"ai_assistant_generation_stopped",
"protected_route_access_granted",
Expand Down Expand Up @@ -82,6 +91,44 @@ describe("analytics event route", () => {
expect(recordEventMock).toHaveBeenCalledTimes(2);
});

it("keeps the cta label and rejects an over-long one", async () => {
const { POST } = await import("@/app/api/analytics/event/route");

const res = await POST(
createRequest([
{ t: Date.now(), ty: "cta_click", uid: "u1", sid: "s1", l: "View Projects" },
{ t: Date.now(), ty: "cta_click", uid: "u1", sid: "s1", l: "x".repeat(500) },
]),
);

expect(res.status).toBe(200);
const first = recordEventMock.mock.calls[0][0];
const second = recordEventMock.mock.calls[1][0];
expect(first.l).toBe("View Projects");
expect(second.l).toHaveLength(200);
});

it("does not write the client ip to the log stream", async () => {
const { POST } = await import("@/app/api/analytics/event/route");
const warn = vi.spyOn(loggerMock, "warn");

const req = new NextRequest("http://localhost:3000/api/analytics/event", {
method: "POST",
headers: {
"content-type": "application/json",
"x-forwarded-for": "203.0.113.7, 70.41.3.18",
},
body: JSON.stringify("not-json"),
});
await POST(req);

expect(warn).toHaveBeenCalled();
const logged = JSON.stringify(warn.mock.calls);
expect(logged).not.toContain("203.0.113.7");
expect(logged).not.toContain("70.41.3.18");
warn.mockRestore();
});

it("rejects non-array bodies", async () => {
const { POST } = await import("@/app/api/analytics/event/route");

Expand Down
10 changes: 6 additions & 4 deletions src/app/api/analytics/event/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ const MAX_EVENTS_PER_MINUTE = 120;
* Ingest endpoint for the self-hosted analytics. Receives a JSON array of
* events via `sendBeacon`. Validates and aggregates into Redis.
*
* Privacy: no IPs are stored; visitor ids are pseudonymous UUIDs.
* Privacy: visitor ids are pseudonymous UUIDs and the client IP is never
* persisted or logged — it is hashed into the rate-limit key only.
*/
export async function POST(request: NextRequest) {
const xff = request.headers.get("x-forwarded-for");
const ip = xff ? (xff.split(",").pop() ?? "").trim() : "unknown";
const limited = rateLimit(`analytics:${ip}`, MAX_EVENTS_PER_MINUTE, 60_000);
if (!limited.allowed) {
logger.warn({ ip, retryAfter: limited.retryAfter }, "analytics ingest rate-limited");
logger.warn({ retryAfter: limited.retryAfter }, "analytics ingest rate-limited");
return NextResponse.json(
{ error: "Too many requests" },
{ status: 429, headers: { "Retry-After": String(limited.retryAfter) } },
Expand All @@ -32,11 +33,11 @@ export async function POST(request: NextRequest) {
try {
body = await request.json();
} catch {
logger.warn({ ip }, "analytics ingest invalid JSON");
logger.warn({}, "analytics ingest invalid JSON");
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!Array.isArray(body)) {
logger.warn({ ip }, "analytics ingest expected array");
logger.warn({}, "analytics ingest expected array");
return NextResponse.json({ error: "Expected an array of events" }, { status: 400 });
}

Expand Down Expand Up @@ -79,5 +80,6 @@ function validateEvent(raw: unknown): TrackedEvent | null {
b: typeof r.b === "string" ? r.b.slice(0, 20) : undefined,
r: typeof r.r === "string" ? r.r.slice(0, MAX_REFERRER_LEN) : undefined,
v: typeof r.v === "number" && Number.isFinite(r.v) ? r.v : undefined,
l: typeof r.l === "string" ? r.l.slice(0, MAX_STRING_LEN) : undefined,
};
}
14 changes: 14 additions & 0 deletions src/components/admin/AdminCharts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,17 @@ export function DevicePie({ data }: { data: { name: string; value: number }[] })
</ResponsiveContainer>
);
}

export function FunnelChart({ data }: { data: { stage: string; value: number }[] }) {
return (
<ResponsiveContainer width="100%" height={Math.max(120, data.length * 36)}>
<BarChart data={data} layout="vertical" margin={{ top: 0, right: 16, bottom: 0, left: 16 }}>
<CartesianGrid strokeDasharray="3 3" strokeOpacity={0.3} horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11 }} allowDecimals={false} />
<YAxis type="category" dataKey="stage" tick={{ fontSize: 11 }} width={96} />
<Tooltip />
<Bar dataKey="value" fill={COLORS.teal} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
);
}
22 changes: 20 additions & 2 deletions src/lib/__tests__/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ describe("analytics", () => {
expect(capture).not.toHaveBeenCalled();
});

it("does not call vercel track when consent is declined", async () => {
const { trackEvent } = await import("@/lib/analytics");
setConsentCookie("declined");
trackEvent("cta_click", { label: "View Projects" });
expect(track).not.toHaveBeenCalled();
});

it("does not capture to posthog when consent is missing", async () => {
const { trackEvent } = await import("@/lib/analytics");
setConsentCookie(null);
Expand All @@ -69,10 +76,21 @@ describe("analytics", () => {
spy.mockRestore();
});

it("passes path and value through to the self-hosted tracker", async () => {
it("passes path, value and label through to the self-hosted tracker", async () => {
const { trackEvent } = await import("@/lib/analytics");
trackEvent("cta_click", { label: "View Projects", path: "/projects", value: 1 });

expect(selfHostedTrack).toHaveBeenCalledWith("cta_click", { path: "/projects", value: 1 });
expect(selfHostedTrack).toHaveBeenCalledWith("cta_click", {
label: "View Projects",
path: "/projects",
value: 1,
});
});

it("drops non-scalar properties before the self-hosted beacon", async () => {
const { trackEvent } = await import("@/lib/analytics");
trackEvent("cta_click", { label: 42, path: "/projects" });

expect(selfHostedTrack).toHaveBeenCalledWith("cta_click", { label: undefined, path: "/projects", value: undefined });
});
});
17 changes: 17 additions & 0 deletions src/lib/__tests__/tracking-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,23 @@ describe("tracking-store", () => {
expect(topPages).toEqual([["/blog", 1]]);
});

it("exposes the browser split and the engagement funnel", async () => {
const { recordEvent, getEngagement } = await import("@/lib/tracking-store");

await recordEvent({ t: Date.now(), ty: "session_start", uid: "u1", sid: "s1", b: "safari" });
await recordEvent({ t: Date.now(), ty: "page_view", uid: "u1", sid: "s1", b: "safari" });
await recordEvent({ t: Date.now(), ty: "scroll_depth", uid: "u1", sid: "s1", v: 25 });
await recordEvent({ t: Date.now(), ty: "scroll_depth", uid: "u1", sid: "s1", v: 50 });
await recordEvent({ t: Date.now(), ty: "scroll_depth", uid: "u1", sid: "s1", v: 100 });
await recordEvent({ t: Date.now(), ty: "quiz_start", uid: "u1", sid: "s1" });
await recordEvent({ t: Date.now(), ty: "quiz_complete", uid: "u1", sid: "s1" });

const engagement = await getEngagement(1);
expect(engagement.browsers.safari).toBe(2);
expect(engagement.scrollDepth).toEqual({ 25: 1, 50: 1, 75: 0, 100: 1 });
expect(engagement.quiz).toEqual({ starts: 1, completes: 1 });
});

it("taxonomy is consistent — every client-emitted name is ingest-accepted", async () => {
const { EVENT_TYPES, EVENT_TYPES_SET } = await import("@/lib/tracking-store");
expect(EVENT_TYPES_SET.size).toBe(EVENT_TYPES.length);
Expand Down
11 changes: 11 additions & 0 deletions src/lib/__tests__/tracking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ describe("tracking client", () => {
expect(sendBeaconMock).toHaveBeenCalledTimes(1);
});

it("buffers the label so cta_click stays segmentable", async () => {
const { track } = await import("@/lib/tracking");

track("cta_click", { path: "/projects", label: "View Projects" });
window.dispatchEvent(new Event("pagehide"));

const blob = sendBeaconMock.mock.calls[0][1] as Blob;
const events = JSON.parse(await blob.text()) as Array<{ ty: string; l?: string }>;
expect(events[0]).toMatchObject({ ty: "cta_click", l: "View Projects" });
});

it("startTrackingSession emits session_start and page_view by default", async () => {
const { startTrackingSession } = await import("@/lib/tracking");

Expand Down
11 changes: 8 additions & 3 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,17 @@ type EventProperties = Record<string, string | number | boolean>;

export function trackEvent(name: EventName, properties?: EventProperties) {
if (typeof window === 'undefined') return;
track(name, properties);
if (process.env.NEXT_PUBLIC_POSTHOG_KEY && getConsent() !== 'declined') {
posthog.capture(name, properties ?? {});
// Vercel Analytics is a third party too — it inherits the same opt-out as
// PostHog and the self-hosted tracker, otherwise declining leaks anyway.
if (getConsent() !== 'declined') {
track(name, properties);
if (process.env.NEXT_PUBLIC_POSTHOG_KEY) {
posthog.capture(name, properties ?? {});
}
}
trackSelfHosted(name, {
path: typeof properties?.path === 'string' ? properties.path : undefined,
value: typeof properties?.value === 'number' ? properties.value : undefined,
label: typeof properties?.label === 'string' ? properties.label : undefined,
});
}
Loading
Loading