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
12 changes: 12 additions & 0 deletions docs/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ Enhanced measurement records page views on App Router navigations (history chang

To check it on production, open the network panel and filter on `google-analytics.com/g/collect`. Before consent the requests carry `gcs=G100`, and after accepting in the banner they carry `gcs=G111`. The visit then shows in GA under Reports → Realtime. Localhost and dev make no `gtag/js` request.

## Request log

The site writes one JSON line to stdout for each page view and API call, which Cloud Logging stores as a structured entry. `src/instrumentation.ts` registers `registerRequestLogging()` in `src/lib/proxy/requestLog.ts` at server startup. It observes Node's `http.server.request.start` diagnostic channel and reads the request's method, path, and headers. A request accepted during startup is logged when its response finishes, if it was not already logged at the start. Visitor counts come from this line, because Cloud Run's own request log records Cloudflare's edge address as the client.

```json
{"severity":"INFO","message":"request","caller":"ip:3f9c0a…","method":"GET","path":"/embed/abc123","host":"developmentfinance.org","kind":"page","userAgent":"Mozilla/5.0 …"}
```

`caller` is the same `ip:<digest>` the `/mcp` proxy sends upstream as `x-dfo-caller` (`src/lib/proxy/caller.ts`): an HMAC of the client address under `CALLER_HMAC_KEY`, with the address taken from `X-Forwarded-For` at the `TRUSTED_PROXY_HOPS` position. It is null when the key is unset or the request carries no usable address. `kind` is `page`, `api` (`/api` and `/mcp`), `rsc` (a client-side navigation) or `prefetch` (a Next.js or browser prefetch). Embeds under `/embed/` are logged as pages. `/_next/` files, images and fonts are not logged. The line holds no raw IP address, query string, cookie or `Authorization` header.

The diagnostic event carries `RSC` and `Next-Router-Prefetch` headers before Next.js normalises them. Logging leaves request bodies unread, so API and MCP payloads stream to their handlers in full. A Next.js proxy would buffer those bodies and truncate them at its default 10 MB limit.

## Guest Studio

The Studio is open without an account: `GUEST_LIMIT` (5, `src/components/organisms/Studio/GuestWall.tsx`) prompts, counted in localStorage, then the prompt is replaced by a sign-in wall. Front-end only by design — a cleared localStorage resets it. Guests get no history, saving or sharing (all need an account).
3 changes: 3 additions & 0 deletions src/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export async function register(): Promise<void> {

await import('../sentry.server.config');

const { registerRequestLogging } = await import('./lib/proxy/requestLog');
registerRequestLogging();

const { assertTrustedHops } = await import('./lib/proxy/caller');

try {
Expand Down
4 changes: 3 additions & 1 deletion src/lib/proxy/caller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,9 @@ export const requestCaller = (incoming: Headers): string | undefined => {
if ('problem' in config) {
if (!warned) {
warned = true;
console.warn(`${config.problem}; MCP calls go upstream without ${CALLER_HEADER}`);
console.warn(
`${config.problem}; MCP calls go upstream without ${CALLER_HEADER} and request logs carry no caller`,
);
}

return undefined;
Expand Down
159 changes: 159 additions & 0 deletions src/lib/proxy/requestLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { subscribe, unsubscribe } from 'node:diagnostics_channel';
import { IncomingMessage } from 'node:http';

import { requestCaller } from './caller.ts';

/**
* One JSON line on stdout per visitor request, registered at server startup, so
* Cloud Logging can count visitors by caller. Cloud Run's own request log
* records Cloudflare's edge address, so it cannot.
*
* `caller` is the same `ip:<digest>` the /mcp proxy sends as x-dfo-caller
* (see caller.ts). The line holds no raw address, query string, cookie or
* auth header.
*/
export type RequestKind = 'page' | 'api' | 'rsc' | 'prefetch';

export type RequestLogEntry = {
severity: 'INFO';
message: 'request';
caller: string | null;
method: string;
path: string;
host: string | null;
kind: RequestKind;
userAgent: string | null;
};

type RequestMetadata = Pick<Request, 'url' | 'method' | 'headers'>;

// Framework files and images and fonts by extension.
const STATIC_ASSET = /^\/_next\/|\.(?:png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf)$/i;

const isApiPath = (path: string): boolean =>
/^\/api(?:\/|$)/.test(path) || /^\/mcp(?:\/|$)/.test(path);

/**
* What a request is, or undefined for a static asset, which is not logged.
* The HTTP diagnostic event carries headers before Next.js normalises them.
*/
export const requestKind = (path: string, headers: Headers): RequestKind | undefined => {
if (STATIC_ASSET.test(path)) {
return undefined;
}

if (isApiPath(path)) {
return 'api';
}

const purpose = headers.get('sec-purpose') ?? headers.get('purpose');

if (headers.has('next-router-prefetch') || purpose?.startsWith('prefetch')) {
return 'prefetch';
}

return headers.get('rsc') === '1' ? 'rsc' : 'page';
};

/** The log entry for a request, or undefined when it is not logged. */
export const requestLogEntry = (
request: RequestMetadata,
caller: string | undefined,
): RequestLogEntry | undefined => {
const path = new URL(request.url).pathname;
const kind = requestKind(path, request.headers);

if (!kind) {
return undefined;
}

return {
severity: 'INFO',
message: 'request',
caller: caller ?? null,
method: request.method,
path,
host: request.headers.get('host'),
kind,
userAgent: request.headers.get('user-agent'),
};
};

const requestMetadata = (request: RequestMetadata | IncomingMessage): RequestMetadata => {
if (!(request instanceof IncomingMessage)) {
return request;
}

const headers = new Headers();

// Keep the original headers even when Next.js has already handled the request.
for (let index = 0; index < request.rawHeaders.length; index += 2) {
const name = request.rawHeaders[index];
const value = request.rawHeaders[index + 1];

if (name === undefined) {
continue;
}

if (value !== undefined) {
headers.append(name, value);
}
}

return {
url: new URL(request.url ?? '/', 'http://localhost').href,
method: request.method ?? 'GET',
headers,
};
};

/** Write the request's log line. Never throws: a logging fault must not fail the request. */
export const logRequest = (request: RequestMetadata | IncomingMessage): void => {
try {
const metadata = requestMetadata(request);
const entry = requestLogEntry(metadata, requestCaller(metadata.headers));

if (entry) {
console.log(JSON.stringify(entry));
}
} catch {
// URL and header errors can include raw request data.
console.error('request log failed');
}
};

const REQUEST_CHANNELS = ['http.server.request.start', 'http.server.response.finish'];

// Share the subscriber and request set across Next dev reloads.
const globalForRequestLog = globalThis as typeof globalThis & {
dfoRequestLogSubscriber?: (message: unknown) => void;
dfoLoggedRequests?: WeakSet<IncomingMessage>;
};
const loggedRequests = (globalForRequestLog.dfoLoggedRequests ??= new WeakSet());

const onServerRequest = (message: unknown): void => {
if (
typeof message === 'object' &&
message !== null &&
'request' in message &&
message.request instanceof IncomingMessage &&
!loggedRequests.has(message.request)
) {
loggedRequests.add(message.request);
logRequest(message.request);
}
};

/** Observe HTTP headers without reading or buffering a request body. */
export const registerRequestLogging = (): void => {
for (const channel of REQUEST_CHANNELS) {
if (globalForRequestLog.dfoRequestLogSubscriber) {
unsubscribe(channel, globalForRequestLog.dfoRequestLogSubscriber);
}

// Finish catches a request accepted before instrumentation registered.
subscribe(channel, onServerRequest);
}

globalForRequestLog.dfoRequestLogSubscriber = onServerRequest;
};
Loading
Loading