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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,13 @@ SUPABASE_JWT_SECRET=your-supabase-jwt-secret-at-least-32-chars
# Database (Supabase PostgreSQL)
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
DIRECT_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres

# PostHog analytics and error tracking
NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_api_key
NEXT_PUBLIC_POSTHOG_HOST=/ingest
POSTHOG_API_KEY=phc_your_project_api_key
POSTHOG_HOST=https://us.i.posthog.com

# PostHog source map upload for production frontend builds
POSTHOG_PERSONAL_API_KEY=phx_your_personal_api_key
POSTHOG_PROJECT_ID=your_project_id
19 changes: 18 additions & 1 deletion apps/web/next.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { NextConfig } from "next";
import { withPostHogConfig } from "@posthog/nextjs-config";

const nextConfig: NextConfig = {
transpilePackages: ["@niche-audio-prep/shared"],
Expand All @@ -16,4 +17,20 @@ const nextConfig: NextConfig = {
},
};

export default nextConfig;
const enablePostHogSourceMaps =
process.env.NODE_ENV === "production" &&
Boolean(process.env.POSTHOG_PERSONAL_API_KEY && process.env.POSTHOG_PROJECT_ID);

export default withPostHogConfig(nextConfig, {
personalApiKey: process.env.POSTHOG_PERSONAL_API_KEY || "",
projectId: process.env.POSTHOG_PROJECT_ID,
host: process.env.POSTHOG_HOST || "https://us.i.posthog.com",
sourcemaps: {
enabled: enablePostHogSourceMaps,
releaseName: "graspful-web",
releaseVersion:
process.env.VERCEL_GIT_COMMIT_SHA ||
process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA,
deleteAfterUpload: true,
},
});
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/resources": "^2.6.1",
"@opentelemetry/sdk-logs": "^0.214.0",
"@posthog/nextjs-config": "^1.9.18",
"@supabase/ssr": "^0.9.0",
"@supabase/supabase-js": "^2.99.0",
"@xyflow/react": "^12.10.1",
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/app/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use client";

import { useEffect } from "react";
import { captureError } from "@/lib/posthog/events";
import { initPostHog } from "@/lib/posthog/client";

export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
initPostHog();
captureError(error, "nextjs-app-error", { digest: error.digest });
}, [error]);

return (
<main className="flex min-h-[60vh] items-center justify-center px-6 py-16">
<section className="max-w-md space-y-4 text-center">
<h2 className="text-2xl font-semibold tracking-tight">
Something went wrong.
</h2>
<p className="text-muted-foreground">
We logged the error. Try again in a moment.
</p>
<button
type="button"
onClick={reset}
className="rounded-md bg-foreground px-4 py-2 text-sm font-medium text-background transition hover:opacity-90"
>
Try again
</button>
</section>
</main>
);
}
42 changes: 42 additions & 0 deletions apps/web/src/app/global-error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"use client";

import { useEffect } from "react";
import { captureError } from "@/lib/posthog/events";
import { initPostHog } from "@/lib/posthog/client";

export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
initPostHog();
captureError(error, "nextjs-global-error", { digest: error.digest });
}, [error]);

return (
<html lang="en">
<body>
<main className="flex min-h-screen items-center justify-center px-6 py-16">
<section className="max-w-md space-y-4 text-center">
<h2 className="text-2xl font-semibold tracking-tight">
Something went wrong.
</h2>
<p>
We logged the error. Try again in a moment.
</p>
<button
type="button"
onClick={reset}
className="rounded-md bg-black px-4 py-2 text-sm font-medium text-white transition hover:opacity-90"
>
Try again
</button>
</section>
</main>
</body>
</html>
);
}
57 changes: 57 additions & 0 deletions apps/web/src/instrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,60 @@ export function register() {
logs.setGlobalLoggerProvider(loggerProvider)
}
}

type HeaderMap =
| Headers
| Record<string, string | string[] | undefined>
| undefined

function readHeader(headers: HeaderMap, name: string): string | undefined {
if (!headers) return undefined
if (typeof (headers as Headers).get === 'function') {
return (headers as Headers).get(name) ?? undefined
}
const value = (headers as Record<string, string | string[] | undefined>)[name]
return Array.isArray(value) ? value.join('; ') : value
}

function getPostHogDistinctId(cookieHeader?: string): string | undefined {
const match = cookieHeader?.match(/ph_phc_.*?_posthog=([^;]+)/)
if (!match?.[1]) return undefined

try {
const parsed = JSON.parse(decodeURIComponent(match[1]))
return typeof parsed?.distinct_id === 'string'
? parsed.distinct_id
: undefined
} catch {
return undefined
}
}

function asError(error: unknown): Error {
if (error instanceof Error) return error
return new Error(typeof error === 'string' ? error : 'Unknown Next.js request error')
}

export const onRequestError = async (
error: unknown,
request: { headers?: HeaderMap; path?: string; method?: string } = {},
context: Record<string, unknown> = {},
) => {
if (process.env.NEXT_RUNTIME !== 'nodejs') return

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, {
source: 'nextjs-on-request-error',
path: request.path,
method: request.method,
route_type: context.routeType,
route_path: context.routePath,
router_kind: context.routerKind,
})
}
20 changes: 15 additions & 5 deletions apps/web/src/lib/posthog/__tests__/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("posthog-js", () => ({
default: {
capture: vi.fn(),
captureException: vi.fn(),
identify: vi.fn(),
isFeatureEnabled: vi.fn(),
__loaded: true,
Expand All @@ -21,6 +22,8 @@ import {
} from "../events";
import posthog from "posthog-js";

const captureExceptionMock = posthog.captureException as ReturnType<typeof vi.fn>;

describe("PostHog event helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand All @@ -34,18 +37,25 @@ describe("PostHog event helpers", () => {
});
});

it("captureError sends $exception event", () => {
it("captureError sends exception through PostHog error tracking", () => {
captureError("Something broke", "auth-form");
expect(posthog.capture).toHaveBeenCalledWith("$exception", {
message: "Something broke",
expect(posthog.captureException).toHaveBeenCalledWith(expect.any(Error), {
source: "auth-form",
});
expect(captureExceptionMock.mock.calls[0][0].message).toBe("Something broke");
});

it("captureError omits source when not provided", () => {
captureError("Oops");
expect(posthog.capture).toHaveBeenCalledWith("$exception", {
message: "Oops",
expect(posthog.captureException).toHaveBeenCalledWith(expect.any(Error), {});
});

it("captureError preserves existing Error instances and properties", () => {
const error = new Error("Original");
captureError(error, "route-error", { digest: "abc123" });
expect(posthog.captureException).toHaveBeenCalledWith(error, {
source: "route-error",
digest: "abc123",
});
});

Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/lib/posthog/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import posthog from "posthog-js";
export function initPostHog() {
if (typeof window === "undefined") return;
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return;
if (posthog.__loaded) return;

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "/ingest",
person_profiles: "identified_only",
capture_pageview: false, // We capture manually for SPA navigation
capture_pageleave: true,
autocapture: true,
capture_exceptions: true,
defaults: "2026-01-30",
});
}

Expand Down
11 changes: 8 additions & 3 deletions apps/web/src/lib/posthog/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,16 @@ export function trackSignIn(userId: string) {

// ── Errors ────────────────────────────────────────────────────────────

export function captureError(message: string, source?: string) {
export function captureError(
error: Error | string,
source?: string,
properties: Record<string, unknown> = {},
) {
if (!isLoaded()) return;
posthog.capture("$exception", {
message,
const exception = error instanceof Error ? error : new Error(error);
posthog.captureException(exception, {
...(source ? { source } : {}),
...properties,
});
}

Expand Down
2 changes: 1 addition & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ KOKORO_TTS_URL=https://your-modal-app--kokoro-tts-speech.modal.run
MODAL_AUTH_KEY=your-modal-auth-key
MODAL_AUTH_SECRET=your-modal-auth-secret

# PostHog (OpenTelemetry logging)
# PostHog analytics, OpenTelemetry logging, and error tracking
POSTHOG_API_KEY=phc_your_project_api_key
POSTHOG_HOST=https://us.i.posthog.com

Expand Down
67 changes: 67 additions & 0 deletions backend/src/shared/application/posthog.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ConfigService } from '@nestjs/config';
import { PostHog } from 'posthog-node';
import { PostHogService } from './posthog.service';

jest.mock('posthog-node', () => ({
PostHog: jest.fn(),
}));

const capture = jest.fn();
const captureException = jest.fn();
const identify = jest.fn();
const shutdown = jest.fn();

function config(values: Record<string, string | undefined>) {
return {
get: jest.fn((key: string, fallback?: string) => values[key] ?? fallback),
} as unknown as ConfigService;
}

describe('PostHogService', () => {
beforeEach(() => {
jest.clearAllMocks();
(PostHog as unknown as jest.Mock).mockImplementation(() => ({
capture,
captureException,
identify,
shutdown,
}));
});

it('initializes PostHog with exception autocapture when configured', () => {
new PostHogService(config({
POSTHOG_API_KEY: 'phc_test',
POSTHOG_HOST: 'https://us.i.posthog.com',
}));

expect(PostHog).toHaveBeenCalledWith('phc_test', {
host: 'https://us.i.posthog.com',
flushAt: 10,
flushInterval: 5000,
enableExceptionAutocapture: true,
});
});

it('captures exceptions through the SDK error tracking helper', () => {
const service = new PostHogService(config({
POSTHOG_API_KEY: 'phc_test',
POSTHOG_HOST: 'https://us.i.posthog.com',
}));
const error = new Error('Boom');

service.captureException(error, 'user-1', { source: 'test' });

expect(captureException).toHaveBeenCalledWith(error, 'user-1', {
source: 'test',
});
});

it('does nothing when no PostHog key is configured', () => {
const service = new PostHogService(config({}));

service.captureException(new Error('Ignored'), 'user-1');

expect(PostHog).not.toHaveBeenCalled();
expect(captureException).not.toHaveBeenCalled();
});
});
12 changes: 2 additions & 10 deletions backend/src/shared/application/posthog.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class PostHogService implements OnModuleDestroy {
host: this.config.get<string>('POSTHOG_HOST') || 'https://us.i.posthog.com',
flushAt: 10,
flushInterval: 5000,
enableExceptionAutocapture: true,
});
}
}
Expand Down Expand Up @@ -50,16 +51,7 @@ export class PostHogService implements OnModuleDestroy {

captureException(error: Error, distinctId: string, properties: Record<string, unknown> = {}) {
if (!this.client) return;
this.client.capture({
distinctId,
event: '$exception',
properties: {
$exception_message: error.message,
$exception_type: error.name,
$exception_stack_trace_raw: error.stack,
...properties,
},
});
this.client.captureException(error, distinctId, properties);
}

async onModuleDestroy() {
Expand Down
Loading
Loading