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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,39 @@ export { middleware };

That's it. Every page view is now tracked server-side.

## Custom server events

Use `analytics().sendEvent()` to emit your own events from the server.

In the App Router (server components, server actions, route handlers) call it with no argument — the
request context is read from `next/headers`:

```typescript
import { analytics } from "@/nextlytics";

export async function signUp(formData: FormData) {
"use server";
// ...create the user...
await (await analytics()).sendEvent("signup", { props: { plan: "pro" } });
}
```

In **Pages Router API routes** `next/headers` is unavailable, so pass the request object:

```typescript
import type { NextApiRequest, NextApiResponse } from "next";
import { analytics } from "@/nextlytics";

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
await (await analytics(req)).sendEvent("email_opened", { props: { campaign: "welcome" } });
res.status(200).end();
}
```

This also works for standalone routes with no preceding page render (e.g. email pixels or redirect
endpoints) — the event is sent without a parent page-view. A NextRequest (App Router route handler)
may be passed the same way.

## Pages Router

Nextlytics also supports the Pages Router. The middleware works the same way, but instead of
Expand Down
149 changes: 113 additions & 36 deletions packages/core/src/server.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import type { ReactNode } from "react";
import type { NextRequest } from "next/server";
import type { NextApiRequest } from "next";
import type { RequestCookies } from "next/dist/server/web/spec-extension/cookies";
import { headers, cookies } from "next/headers";
import { removeSensitiveHeaders } from "./headers";
import { headerNames, restoreServerComponentContext } from "./server-component-context";
import {
headerNames,
restoreServerComponentContext,
LAST_PAGE_RENDER_ID_COOKIE,
} from "./server-component-context";
import { resolveAnonymousUser } from "./anonymous-user";
import { NextlyticsClient } from "./client";
import type {
Expand Down Expand Up @@ -268,16 +275,25 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult {
);
}

const analytics = async () => {
const headersList = await headers();
const cookieStore = await cookies();
const pageRenderId = headersList.get(headerNames.pageRenderId);

const serverContext = createServerContextFromHeaders(headersList);
const analytics = async (req?: NextRequest | NextApiRequest) => {
// App Router (no `req`) reads context from `next/headers`. Pages Router API
// routes pass `req` (NextApiRequest) since `next/headers` throws there; App
// Router Route Handlers may pass `req` (NextRequest) too.
const source = req ? normalizeRequest(req) : await normalizeFromNextHeaders();

// Link the event to the page render that triggered it, when there is one.
// Standalone routes (e.g. email pixels) have no page render — parentEventId
// is omitted in that case rather than failing.
const pageRenderId =
source.headers.get(headerNames.pageRenderId) ||
source.cookies.get(LAST_PAGE_RENDER_ID_COOKIE)?.value ||
undefined;

const serverContext = buildServerContext(source);
const ctx: RequestContext = {
headers: headersList,
cookies: cookieStore,
path: headersList.get(headerNames.pathname) || "",
headers: source.headers,
cookies: source.cookies,
path: source.path,
};

// Resolve anonymous user ID
Expand All @@ -300,15 +316,18 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult {
eventName: string,
opts?: { props?: Record<string, unknown> }
): Promise<{ ok: boolean }> => {
if (!pageRenderId) {
// In the App Router no-arg path a missing pageRenderId means middleware
// never ran — keep that as a hard error. With an explicit `req` the
// caller built the context themselves, so a missing render id is fine.
if (!pageRenderId && !req) {
console.error("[Nextlytics] analytics() requires nextlyticsMiddleware");
return { ok: false };
}

const event: NextlyticsEvent = {
origin: "server",
eventId: generateId(),
parentEventId: pageRenderId,
...(pageRenderId ? { parentEventId: pageRenderId } : {}),
type: eventName,
collectedAt: new Date().toISOString(),
anonymousUserId,
Expand All @@ -332,37 +351,95 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult {
};
}

function createServerContextFromHeaders(
headersList: Awaited<ReturnType<typeof headers>>
): ServerEventContext {
const rawHeaders: Record<string, string> = {};
headersList.forEach((value, key) => {
rawHeaders[key] = value;
/** Request data `analytics()` needs, normalized across its three sources:
* App Router (`next/headers`), App Router Route Handlers (NextRequest), and
* Pages Router API routes (NextApiRequest). */
type NormalizedRequest = {
headers: Headers;
cookies: Pick<RequestCookies, "get" | "getAll" | "has">;
path: string;
search: Record<string, string[]>;
method: string;
};

function searchToRecord(params: URLSearchParams): Record<string, string[]> {
const out: Record<string, string[]> = {};
params.forEach((value, key) => {
(out[key] ??= []).push(value);
});
const requestHeaders = removeSensitiveHeaders(rawHeaders);
return out;
}

const pathname = headersList.get(headerNames.pathname) || "";
const search = headersList.get(headerNames.search) || "";
const searchParams: Record<string, string[]> = {};
/** NextRequest exposes a Web `Headers` (has `.get`); NextApiRequest exposes a
* plain `IncomingHttpHeaders` object (no `.get`). */
function isNextApiRequest(req: NextRequest | NextApiRequest): req is NextApiRequest {
return typeof (req.headers as { get?: unknown })?.get !== "function";
}

if (search) {
const params = new URLSearchParams(search);
params.forEach((value, key) => {
if (!searchParams[key]) {
searchParams[key] = [];
}
searchParams[key].push(value);
});
async function normalizeFromNextHeaders(): Promise<NormalizedRequest> {
const [_cookies, _headers] = await Promise.all([cookies(), headers()]);
return {
headers: _headers,
cookies: _cookies,
path: _headers.get(headerNames.pathname) || "",
search: searchToRecord(new URLSearchParams(_headers.get(headerNames.search) || "")),
method: "GET",
};
}

function normalizeRequest(req: NextRequest | NextApiRequest): NormalizedRequest {
if (!isNextApiRequest(req)) {
return {
headers: req.headers,
cookies: req.cookies,
path: req.nextUrl.pathname,
search: searchToRecord(req.nextUrl.searchParams),
method: req.method,
};
}

// Pages Router API route (req/res).
const headersList = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value !== undefined) {
headersList.set(key, Array.isArray(value) ? value.join(", ") : value);
}
}

const cookieMap = req.cookies || {};
const cookieStore: Pick<RequestCookies, "get" | "getAll" | "has"> = {
get: (name: string) => {
const value = cookieMap[name];
return value === undefined ? undefined : { name, value };
},
getAll: () => Object.entries(cookieMap).map(([name, value]) => ({ name, value })),
has: (name: string) => name in cookieMap,
} as Pick<RequestCookies, "get" | "getAll" | "has">;

const url = new URL(req.url || "/", `http://${headersList.get("host") || "localhost"}`);
return {
headers: headersList,
cookies: cookieStore,
path: url.pathname,
search: searchToRecord(url.searchParams),
method: req.method || "GET",
};
}

function buildServerContext(source: NormalizedRequest): ServerEventContext {
const rawHeaders: Record<string, string> = {};
source.headers.forEach((value, key) => {
rawHeaders[key] = value;
});

return {
collectedAt: new Date(),
host: headersList.get("host") || "",
method: "GET",
path: pathname,
search: searchParams,
ip: headersList.get("x-forwarded-for")?.split(",")[0]?.trim() || "",
requestHeaders,
host: source.headers.get("host") || "",
method: source.method,
path: source.path,
search: source.search,
ip: source.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "",
requestHeaders: removeSensitiveHeaders(rawHeaders),
responseHeaders: {},
};
}
16 changes: 12 additions & 4 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ export interface NextlyticsEvent {
}

import type { RequestCookies } from "next/dist/server/web/spec-extension/cookies";
import type { NextMiddleware } from "next/server";
import type { NextMiddleware, NextRequest } from "next/server";
import type { NextApiRequest } from "next";

export type AnonymousUserResult = {
/** Anonymous user identifier */
Expand Down Expand Up @@ -251,7 +252,7 @@ export type NextlyticsBackendFactory = (ctx: RequestContext) => NextlyticsBacken

/** Server-side analytics API */
export type NextlyticsServerSide = {
/** Send custom event from server component/action */
/** Send custom event from a server component, server action, or API route */
sendEvent: (
eventName: string,
opts?: { props?: Record<string, unknown> }
Expand Down Expand Up @@ -299,8 +300,15 @@ export type ClientRequestResult = {

/** Return value from Nextlytics() */
export type NextlyticsResult = {
/** Get server-side analytics API */
analytics: () => Promise<NextlyticsServerSide>;
/** Get server-side analytics API.
*
* App Router (server components, server actions, route handlers): call with no
* argument — context is read from `next/headers`.
*
* Pages Router API routes: `next/headers` is unavailable there, so pass the
* request (`analytics(req)`). A NextRequest (App Router route handler) is also
* accepted. */
analytics: (req?: NextRequest | NextApiRequest) => Promise<NextlyticsServerSide>;
/** Middleware to intercept requests */
middleware: NextMiddleware;
/** Manually dispatch event (returns two-phase result) */
Expand Down
Loading