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
41 changes: 41 additions & 0 deletions apps/web/src/__tests__/lib/posthog-server-config.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
45 changes: 42 additions & 3 deletions apps/web/src/app/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -63,21 +64,59 @@ 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}`,
"Content-Type": "application/json",
},
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
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/app/auth/confirm/route.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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
Expand Down
33 changes: 28 additions & 5 deletions apps/web/src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
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({
resource: resourceFromAttributes({ 'service.name': 'graspful-web' }),
processors: [
new BatchLogRecordProcessor(
new OTLPLogExporter({
url: `${host}/i/v1/logs`,
url: getPostHogLogsEndpoint(),
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Expand Down Expand Up @@ -69,14 +69,37 @@ 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

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,
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/lib/posthog/server-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";

type PostHogEnv = Record<string, string | undefined>;

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`;
}
37 changes: 37 additions & 0 deletions apps/web/src/lib/posthog/server-logs.ts
Original file line number Diff line number Diff line change
@@ -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<ServerLogLevel, SeverityNumber> = {
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();
});
}
9 changes: 5 additions & 4 deletions apps/web/src/lib/posthog/server.ts
Original file line number Diff line number Diff line change
@@ -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,
});
Expand Down
Loading