From 376c93d3cd6632ba4d5bb3c0339806c963c6e796 Mon Sep 17 00:00:00 2001 From: willwearing Date: Tue, 5 May 2026 11:25:12 -0600 Subject: [PATCH] Add PostHog web server logs --- .../lib/posthog-server-config.test.ts | 41 +++++++++++++++++ apps/web/src/app/auth/callback/route.ts | 45 +++++++++++++++++-- apps/web/src/app/auth/confirm/route.ts | 15 +++++++ apps/web/src/instrumentation.ts | 33 +++++++++++--- apps/web/src/lib/posthog/server-config.ts | 33 ++++++++++++++ apps/web/src/lib/posthog/server-logs.ts | 37 +++++++++++++++ apps/web/src/lib/posthog/server.ts | 9 ++-- 7 files changed, 201 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/__tests__/lib/posthog-server-config.test.ts create mode 100644 apps/web/src/lib/posthog/server-config.ts create mode 100644 apps/web/src/lib/posthog/server-logs.ts diff --git a/apps/web/src/__tests__/lib/posthog-server-config.test.ts b/apps/web/src/__tests__/lib/posthog-server-config.test.ts new file mode 100644 index 0000000..c782f0a --- /dev/null +++ b/apps/web/src/__tests__/lib/posthog-server-config.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { + getPostHogLogsEndpoint, + getPostHogProjectToken, + getPostHogServerHost, +} from "@/lib/posthog/server-config"; + +describe("PostHog server config", () => { + it("uses the server-side PostHog host before the browser ingest proxy", () => { + const env = { + POSTHOG_HOST: "https://eu.i.posthog.com/", + NEXT_PUBLIC_POSTHOG_HOST: "/ingest", + }; + + expect(getPostHogServerHost(env)).toBe("https://eu.i.posthog.com"); + expect(getPostHogLogsEndpoint(env)).toBe("https://eu.i.posthog.com/i/v1/logs"); + }); + + it("does not send server OTLP logs to a relative browser proxy", () => { + expect(getPostHogLogsEndpoint({ NEXT_PUBLIC_POSTHOG_HOST: "/ingest" })).toBe( + "https://us.i.posthog.com/i/v1/logs", + ); + }); + + it("falls back to an absolute public host when a server host is not configured", () => { + expect( + getPostHogServerHost({ + NEXT_PUBLIC_POSTHOG_HOST: "https://eu.i.posthog.com/", + }), + ).toBe("https://eu.i.posthog.com"); + }); + + it("prefers the private project token on the server", () => { + expect( + getPostHogProjectToken({ + POSTHOG_API_KEY: "phc_private", + NEXT_PUBLIC_POSTHOG_KEY: "phc_public", + }), + ).toBe("phc_private"); + }); +}); diff --git a/apps/web/src/app/auth/callback/route.ts b/apps/web/src/app/auth/callback/route.ts index f8b1cf3..cb7fbf6 100644 --- a/apps/web/src/app/auth/callback/route.ts +++ b/apps/web/src/app/auth/callback/route.ts @@ -2,6 +2,7 @@ import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; import { NextResponse, type NextRequest } from "next/server"; import { getServerPostHog } from "@/lib/posthog/server"; +import { emitServerLog, flushServerLogsAfterResponse } from "@/lib/posthog/server-logs"; import { getDefaultAuthRedirectPath, getHostSurface, getRequestHost } from "@/lib/hosts"; import { resolveBrand } from "@/lib/brand/resolve"; @@ -63,7 +64,7 @@ export async function GET(request: NextRequest) { const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:3000/api/v1"; try { const brand = await resolveBrand(hostname, request.headers.get("cookie")); - await fetch(`${backendUrl}/auth/provision`, { + const provisionResponse = await fetch(`${backendUrl}/auth/provision`, { method: "POST", headers: { Authorization: `Bearer ${token}`, @@ -71,13 +72,51 @@ export async function GET(request: NextRequest) { }, body: JSON.stringify({ brandOrgSlug: brand.orgSlug }), }); - } catch { - // Non-fatal + if (!provisionResponse.ok) { + emitServerLog( + "auth", + "WARN", + "Auth callback provisioning request failed", + { + "http.status_code": provisionResponse.status, + "brand.org_slug": brand.orgSlug, + "auth.redirect": redirect, + }, + ); + flushServerLogsAfterResponse(); + } + } catch (provisionError) { + emitServerLog( + "auth", + "ERROR", + "Auth callback provisioning threw", + { + "error.message": + provisionError instanceof Error + ? provisionError.message + : "Unknown provisioning error", + "auth.redirect": redirect, + }, + ); + flushServerLogsAfterResponse(); } } return NextResponse.redirect(new URL(redirect, origin)); } + + emitServerLog("auth", "WARN", "Auth callback code exchange failed", { + "error.message": error.message, + "auth.redirect": redirect, + }); + flushServerLogsAfterResponse(); + } + + if (!code) { + emitServerLog("auth", "WARN", "Auth callback missing code", { + "auth.redirect": redirect, + }); + flushServerLogsAfterResponse(); } // Auth error -- redirect to sign-in diff --git a/apps/web/src/app/auth/confirm/route.ts b/apps/web/src/app/auth/confirm/route.ts index a8b3fd5..b57db8d 100644 --- a/apps/web/src/app/auth/confirm/route.ts +++ b/apps/web/src/app/auth/confirm/route.ts @@ -1,6 +1,7 @@ import { createServerClient } from "@supabase/ssr"; import { type EmailOtpType } from "@supabase/supabase-js"; import { NextRequest, NextResponse } from "next/server"; +import { emitServerLog, flushServerLogsAfterResponse } from "@/lib/posthog/server-logs"; import { getDefaultAuthRedirectPath, getHostSurface, getRequestHost } from "@/lib/hosts"; export async function GET(request: NextRequest) { @@ -47,6 +48,20 @@ export async function GET(request: NextRequest) { } return response; } + + emitServerLog("auth", "WARN", "Auth confirmation token verification failed", { + "error.message": error.message, + "auth.next": next, + "auth.otp_type": type, + }); + flushServerLogsAfterResponse(); + } else { + emitServerLog("auth", "WARN", "Auth confirmation missing token parameters", { + "auth.next": next, + "auth.has_token_hash": Boolean(tokenHash), + "auth.has_type": Boolean(type), + }); + flushServerLogsAfterResponse(); } // Verification failed — redirect to sign-in with error context diff --git a/apps/web/src/instrumentation.ts b/apps/web/src/instrumentation.ts index 82d077e..5b4dd25 100644 --- a/apps/web/src/instrumentation.ts +++ b/apps/web/src/instrumentation.ts @@ -1,10 +1,10 @@ import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs' import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' -import { logs } from '@opentelemetry/api-logs' +import { logs, SeverityNumber } from '@opentelemetry/api-logs' import { resourceFromAttributes } from '@opentelemetry/resources' +import { getPostHogLogsEndpoint, getPostHogProjectToken } from './lib/posthog/server-config' -const token = process.env.NEXT_PUBLIC_POSTHOG_KEY -const host = (process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://us.i.posthog.com').replace(/\/$/, '') +const token = getPostHogProjectToken() export const loggerProvider = token ? new LoggerProvider({ @@ -12,7 +12,7 @@ export const loggerProvider = token processors: [ new BatchLogRecordProcessor( new OTLPLogExporter({ - url: `${host}/i/v1/logs`, + url: getPostHogLogsEndpoint(), headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', @@ -69,6 +69,29 @@ export const onRequestError = async ( ) => { if (process.env.NEXT_RUNTIME !== 'nodejs') return + const errorObject = asError(error) + loggerProvider?.getLogger('nextjs').emit({ + severityNumber: SeverityNumber.ERROR, + severityText: 'ERROR', + body: `Next.js request error: ${errorObject.message}`, + attributes: { + source: 'nextjs-on-request-error', + path: request.path, + method: request.method, + ...(typeof context.routeType === 'string' && { route_type: context.routeType }), + ...(typeof context.routePath === 'string' && { route_path: context.routePath }), + ...(typeof context.routerKind === 'string' && { router_kind: context.routerKind }), + 'error.type': errorObject.name, + 'error.message': errorObject.message, + ...(errorObject.stack && { 'error.stack': errorObject.stack }), + }, + }) + try { + await loggerProvider?.forceFlush() + } catch { + // Logging should never block exception capture. + } + const { getServerPostHog } = await import('./lib/posthog/server') const posthog = getServerPostHog() if (!posthog) return @@ -76,7 +99,7 @@ export const onRequestError = async ( const cookie = readHeader(request.headers, 'cookie') const distinctId = getPostHogDistinctId(cookie) || 'nextjs-server' - posthog.captureException(asError(error), distinctId, { + posthog.captureException(errorObject, distinctId, { source: 'nextjs-on-request-error', path: request.path, method: request.method, diff --git a/apps/web/src/lib/posthog/server-config.ts b/apps/web/src/lib/posthog/server-config.ts new file mode 100644 index 0000000..7d595b2 --- /dev/null +++ b/apps/web/src/lib/posthog/server-config.ts @@ -0,0 +1,33 @@ +const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; + +type PostHogEnv = Record; + +function trimTrailingSlash(value: string): string { + return value.replace(/\/$/, ""); +} + +function isAbsoluteHttpUrl(value: string): boolean { + return value.startsWith("https://") || value.startsWith("http://"); +} + +export function getPostHogProjectToken(env: PostHogEnv = process.env): string | undefined { + return env.POSTHOG_API_KEY || env.NEXT_PUBLIC_POSTHOG_KEY; +} + +export function getPostHogServerHost(env: PostHogEnv = process.env): string { + const serverHost = env.POSTHOG_HOST?.trim(); + if (serverHost) { + return trimTrailingSlash(serverHost); + } + + const publicHost = env.NEXT_PUBLIC_POSTHOG_HOST?.trim(); + if (publicHost && isAbsoluteHttpUrl(publicHost)) { + return trimTrailingSlash(publicHost); + } + + return DEFAULT_POSTHOG_HOST; +} + +export function getPostHogLogsEndpoint(env: PostHogEnv = process.env): string { + return `${getPostHogServerHost(env)}/i/v1/logs`; +} diff --git a/apps/web/src/lib/posthog/server-logs.ts b/apps/web/src/lib/posthog/server-logs.ts new file mode 100644 index 0000000..e66c231 --- /dev/null +++ b/apps/web/src/lib/posthog/server-logs.ts @@ -0,0 +1,37 @@ +import { SeverityNumber, type LogAttributes } from "@opentelemetry/api-logs"; +import { after } from "next/server"; +import { loggerProvider } from "@/instrumentation"; + +type ServerLogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR"; + +const severityByLevel: Record = { + DEBUG: SeverityNumber.DEBUG, + INFO: SeverityNumber.INFO, + WARN: SeverityNumber.WARN, + ERROR: SeverityNumber.ERROR, +}; + +export function emitServerLog( + loggerName: string, + severityText: ServerLogLevel, + body: string, + attributes: LogAttributes = {}, +) { + if (process.env.NEXT_RUNTIME === "edge") return; + + loggerProvider?.getLogger(loggerName).emit({ + severityNumber: severityByLevel[severityText], + severityText, + body, + attributes, + }); +} + +export function flushServerLogsAfterResponse() { + const provider = loggerProvider; + if (process.env.NEXT_RUNTIME === "edge" || !provider) return; + + after(async () => { + await provider.forceFlush(); + }); +} diff --git a/apps/web/src/lib/posthog/server.ts b/apps/web/src/lib/posthog/server.ts index 0a5a648..62121b4 100644 --- a/apps/web/src/lib/posthog/server.ts +++ b/apps/web/src/lib/posthog/server.ts @@ -1,14 +1,15 @@ import { PostHog } from "posthog-node"; +import { getPostHogProjectToken, getPostHogServerHost } from "./server-config"; let posthogServer: PostHog | null = null; export function getServerPostHog(): PostHog | null { - if (!process.env.POSTHOG_API_KEY) return null; + const token = getPostHogProjectToken(); + if (!token) return null; if (!posthogServer) { - posthogServer = new PostHog(process.env.POSTHOG_API_KEY, { - host: - process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com", + posthogServer = new PostHog(token, { + host: getPostHogServerHost(), flushAt: 1, flushInterval: 0, });