diff --git a/.env.example b/.env.example index 794129b3..d733dcfd 100644 --- a/.env.example +++ b/.env.example @@ -53,12 +53,28 @@ CRON_SECRET=generate-a-random-secret # BOLAGSVERKET_ARELLE_VALIDATOR_URL= # BOLAGSVERKET_ARELLE_VALIDATOR_TOKEN= +# ── Optional: product analytics + error tracking (PostHog) ─ +# Hosted only. Self-hosted deployments never load PostHog: isAnalyticsEnabled() +# (lib/analytics/enabled.ts) short-circuits on NEXT_PUBLIC_SELF_HOSTED=true, and +# no __NEXT_PUBLIC_POSTHOG_*__ sentinel is baked into the Docker image, so an +# operator cannot accidentally ship their users' behaviour to our project. +# +# The token is the PUBLIC project token (phc_...). It is embedded in the client +# bundle by design and is not a secret. Leave unset to run with analytics off. +# Browser traffic goes through the same-origin /rl rewrite in next.config.ts; +# NEXT_PUBLIC_POSTHOG_HOST is only used by the server-side SDK. +# NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN= +# NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com + # ── Optional: error tracking / observability ────────────── # The app routes every error-level log line, and anything flagged -# `alert: true`, to a provider-agnostic sink (lib/observability). No vendor is -# wired up: the sink is a NO-OP until an adapter is registered with -# registerObservabilitySink(). Setting these variables alone changes nothing, -# and self-hosted installs can leave them unset forever. +# `alert: true`, to a provider-agnostic sink (lib/observability). When the +# PostHog token above is set, lib/init.ts registers the PostHog adapter +# (lib/analytics/posthog-observability.ts) as that sink; otherwise the sink +# stays a NO-OP, the PostHog client is never constructed and nothing is ever +# sent. (The SDK is still bundled in those builds, since the imports are +# static; it simply never initialises.) The variables below are for a +# DIFFERENT vendor adapter and still change nothing on their own. # # Names are generic placeholders. When a provider is picked, either keep these # and read them in the adapter, or replace them with the vendor's own names. diff --git a/.gitignore b/.gitignore index 863f28b5..c6cee0f3 100644 --- a/.gitignore +++ b/.gitignore @@ -95,4 +95,9 @@ scripts/reopen-bokslut.sql .claude/plans/write-up-a-plan-streamed-fiddle.md /ingaende-balanser-test.csv .agents -.codex \ No newline at end of file +.codex +# PostHog wizard scratch: vendor reference docs downloaded by +# `npx @posthog/wizard`, useful locally (and for `wizard audit`) but not +# part of the app. The integration itself lives in lib/analytics/ and +# instrumentation-client.ts. +.posthog/ diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 3c2d6170..05b96028 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -4,6 +4,7 @@ import DashboardNav from '@/components/dashboard/DashboardNav' import { MainContainer } from '@/components/dashboard/MainContainer' import CompanyTabSync from '@/components/dashboard/CompanyTabSync' import { RecaptIdentify } from '@/components/RecaptIdentify' +import AnalyticsIdentify from '@/components/AnalyticsIdentify' import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider' import AgentTrigger from '@/components/agent/AgentTrigger' import LazyCommandPalette from '@/components/common/LazyCommandPalette' @@ -13,7 +14,7 @@ import { getExtensionNavItems } from '@/lib/extensions/sectors' import { CompanyProvider } from '@/contexts/CompanyContext' import { getCompanyEntitlements } from '@/lib/entitlements/has-capability' import { getBranding } from '@/lib/branding/service' -import type { EntityType, CompanyRole, Team } from '@/types' +import type { AccountingFramework, EntityType, CompanyRole, Team } from '@/types' import { getDashboardAuthContext, getDashboardCompanyId, @@ -330,6 +331,25 @@ export default async function DashboardLayout({ displayName={settings?.company_name || undefined} /> )} + {!isSandbox && ( + + )} ) diff --git a/components/AnalyticsIdentify.tsx b/components/AnalyticsIdentify.tsx new file mode 100644 index 00000000..6578120e --- /dev/null +++ b/components/AnalyticsIdentify.tsx @@ -0,0 +1,85 @@ +'use client' + +import { useEffect } from 'react' +import posthog from 'posthog-js' +import { isAnalyticsEnabled } from '@/lib/analytics/enabled' +import { + buildPersonProperties, + buildGroupProperties, + type AnalyticsCompanyInput, + type AnalyticsUserInput, +} from '@/lib/analytics/properties' + +/** + * Attaches the logged-in user and their active company to PostHog. + * + * Mounted from app/(dashboard)/layout.tsx, which is the only place that has + * both the auth user and the resolved company in one render, and gated there + * on `!isSandbox` so demo companies never pollute funnels or get surveyed. + * + * identify() runs on every dashboard load rather than only at login, per + * PostHog's guidance: with `persistence: 'memory'` (see + * instrumentation-client.ts) nothing survives a hard reload, so re-identifying + * on each load is what keeps person-level analytics correct without storing + * anything on the device. + * + * A useEffect is correct here despite the general rule against it: this + * synchronises with an external, non-React system (the PostHog SDK). + */ +export default function AnalyticsIdentify({ + user, + company, +}: { + user: AnalyticsUserInput + company: AnalyticsCompanyInput +}) { + const { userId, email, fullName, role } = user + const { + id: companyId, + name: companyName, + entityType, + accountingFramework, + paysSalaries, + trialEndsAt, + capabilities, + } = company + + // The dashboard layout rebuilds the props objects (and the capabilities + // array) on every render, so depending on them directly would re-fire + // identify on every navigation. Depend on primitives, and collapse the + // array to a stable string key. + const capabilityKey = capabilities ? [...capabilities].sort().join(',') : '' + + useEffect(() => { + if (!isAnalyticsEnabled()) return + + posthog.identify(userId, buildPersonProperties({ userId, email, fullName, role })) + posthog.group( + 'company', + companyId, + buildGroupProperties({ + id: companyId, + name: companyName, + entityType, + accountingFramework, + paysSalaries, + trialEndsAt, + capabilities: capabilityKey ? capabilityKey.split(',') : undefined, + }) + ) + }, [ + userId, + email, + fullName, + role, + companyId, + companyName, + entityType, + accountingFramework, + paysSalaries, + trialEndsAt, + capabilityKey, + ]) + + return null +} diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 44cd19eb..5d71ca96 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -50,6 +50,7 @@ import { getBranding } from '@/lib/branding/service' import { ENABLED_EXTENSION_IDS as _ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import { resolveIcon } from '@/lib/extensions/icon-resolver' import { clearRecaptIdentity } from '@/lib/recapt' +import { resetAnalyticsIdentity } from '@/lib/analytics/reset' import { SupportLink } from '@/components/ui/support-link' import CompanySwitcher from '@/components/dashboard/CompanySwitcher' import UserMenu from '@/components/dashboard/UserMenu' @@ -357,6 +358,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa const handleLogout = async () => { clearRecaptIdentity() + resetAnalyticsIdentity() await supabase.auth.signOut() router.push(isSandbox ? '/sandbox' : '/login') } diff --git a/components/settings/sections/AccountSettingsContent.tsx b/components/settings/sections/AccountSettingsContent.tsx index 821e5958..fdb7c974 100644 --- a/components/settings/sections/AccountSettingsContent.tsx +++ b/components/settings/sections/AccountSettingsContent.tsx @@ -23,6 +23,7 @@ import { import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' import { useSettings } from '@/components/settings/useSettings' import { clearRecaptIdentity } from '@/lib/recapt' +import { resetAnalyticsIdentity } from '@/lib/analytics/reset' import { useToast } from '@/components/ui/use-toast' import { SUPPORTED_LOCALES, type Locale } from '@/i18n/config' @@ -92,6 +93,7 @@ export function AccountSettingsContent() { async function handleLogout() { clearRecaptIdentity() + resetAnalyticsIdentity() await supabase.auth.signOut() router.push('/login') } diff --git a/instrumentation-client.ts b/instrumentation-client.ts new file mode 100644 index 00000000..fa5e56d6 --- /dev/null +++ b/instrumentation-client.ts @@ -0,0 +1,74 @@ +import posthog from 'posthog-js' +import { isAnalyticsEnabled, warnIfAnalyticsMisconfigured } from '@/lib/analytics/enabled' + +/** + * Hostnames that get the X-POSTHOG-DISTINCT-ID / X-POSTHOG-SESSION-ID headers, + * which is what lets a server error captured in instrumentation.ts link back + * to this user's session replay. + * + * Deliberately our own origin only. Listing a Supabase or third-party host + * here would leak PostHog identifiers to them. PostHog matches on hostname + * alone, so no protocol and no port ('localhost', never 'localhost:3000'). + */ +function tracingHosts(): string[] { + const hosts = ['localhost', '127.0.0.1'] + const appUrl = process.env.NEXT_PUBLIC_APP_URL + if (appUrl) { + try { + hosts.push(new URL(appUrl).hostname) + } catch { + // Malformed NEXT_PUBLIC_APP_URL: skip rather than break init. + } + } + return hosts +} + +/** + * Client-side PostHog initialisation. + * + * This file is the ONLY place posthog.init() is called. Next.js 15.3+ runs + * `instrumentation-client` before hydration, which is what PostHog's own + * Next.js guidance requires; deliberately NOT combined with a + * wrapper, which their example calls out as a mistake. + * + * Three choices here are deliberate and worth not "fixing": + * + * 1. `api_host: '/rl'` routes every request through the same-origin rewrite + * in next.config.ts. That keeps PostHog first-party, so the strict CSP + * needs no third-party hosts at all (`connect-src 'self'` already covers + * it) and ad blockers have nothing to match on. The path must stay in the + * proxy.ts matcher exclusion or middleware bounces it to /login. + * + * 2. `persistence: 'memory'` stores nothing on the device. That is what lets + * us run analytics without a cookie-consent banner. The cost is that an + * anonymous visitor's identity does not survive a hard reload; everything + * post-login is unaffected because AnalyticsIdentify re-identifies on + * every dashboard load. Note that surveys still write their own + * `seenSurvey_*` flags straight to localStorage, bypassing this setting: + * that is functional UI state ("don't ask again"), not tracking. + * + * 3. `maskTextSelector: '*'` (PostHog's documented way to mask ALL text) on + * top of the default `maskAllInputs`. This is an accounting app: org + * numbers (which for an enskild firma ARE the owner's personnummer), + * customer names, balances and invoice amounts are rendered as ordinary + * text, and PostHog masks inputs but NOT text by default. Replays are for + * seeing WHERE a user gets stuck, never WHAT their books say. + */ +if (warnIfAnalyticsMisconfigured() && isAnalyticsEnabled()) { + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, { + api_host: '/rl', + ui_host: 'https://eu.posthog.com', + defaults: '2026-05-30', + // Only create person profiles for users we actually identify: logged-out + // visitors stay anonymous and cheap. + person_profiles: 'identified_only', + persistence: 'memory', + capture_exceptions: true, + tracing_headers: tracingHosts(), + session_recording: { + maskAllInputs: true, + maskTextSelector: '*', + }, + debug: process.env.NODE_ENV === 'development', + }) +} diff --git a/instrumentation.ts b/instrumentation.ts index 82148e62..c6cc6494 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -1,4 +1,47 @@ +import type { Instrumentation } from 'next' + export async function register() { // Instrumentation hook: currently a no-op. // Add runtime-specific setup here if needed. } + +/** + * Server-side error capture for PostHog Error Tracking. + * + * Next.js calls this for every uncaught error in a route handler, server + * component or server action. Client-side exceptions are handled separately + * by `capture_exceptions` in instrumentation-client.ts. + * + * posthog-node is imported lazily inside the handler rather than at module + * scope: `register()` runs in every runtime Next.js boots, and pulling a + * Node-only SDK into that graph unconditionally is how instrumentation files + * break edge/build-time compilation. A dynamic import keeps the cost on the + * error path only. + * + * The distinct id comes from the `X-POSTHOG-DISTINCT-ID` header that + * posthog-js attaches to same-origin fetches (see `tracing_headers` in + * instrumentation-client.ts), which is what links a server error back to the + * user's session replay. Absent that header the error is still captured, just + * unattributed. + */ +export const onRequestError: Instrumentation.onRequestError = async (err, request) => { + try { + const { getPostHogServer, flushAnalytics } = await import('@/lib/analytics/posthog-server') + const posthog = getPostHogServer() + if (!posthog) return + + const headers = request.headers as Record | undefined + const raw = headers?.['x-posthog-distinct-id'] + const distinctId = Array.isArray(raw) ? raw[0] : raw + + posthog.captureException(err instanceof Error ? err : new Error(String(err)), distinctId, { + path: request.path, + method: request.method, + }) + // Vercel tears the function down per invocation: without the awaited + // flush the enqueued event is silently dropped. + await flushAnalytics() + } catch { + // Telemetry must never turn a handled error into a second failure. + } +} diff --git a/lib/analytics/__tests__/enabled.test.ts b/lib/analytics/__tests__/enabled.test.ts new file mode 100644 index 00000000..38149372 --- /dev/null +++ b/lib/analytics/__tests__/enabled.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { isAnalyticsEnabled, warnIfAnalyticsMisconfigured, POSTHOG_TOKEN_VAR } from '../enabled' + +describe('analytics gate', () => { + afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + }) + + describe('isAnalyticsEnabled', () => { + it('returns false when NEXT_PUBLIC_SELF_HOSTED is true, even with a token', () => { + vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true') + vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', 'phc_test') + expect(isAnalyticsEnabled()).toBe(false) + }) + + it('returns true when a token is set and not self-hosted', () => { + vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false') + vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', 'phc_test') + expect(isAnalyticsEnabled()).toBe(true) + }) + + it('returns false when the token is unset', () => { + vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false') + vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', '') + expect(isAnalyticsEnabled()).toBe(false) + }) + }) + + describe('warnIfAnalyticsMisconfigured', () => { + it('returns true and stays quiet when configured', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false') + vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', 'phc_test') + expect(warnIfAnalyticsMisconfigured()).toBe(true) + expect(warn).not.toHaveBeenCalled() + }) + + it('warns in development when the token is missing', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false') + vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', '') + vi.stubEnv('NODE_ENV', 'development') + expect(warnIfAnalyticsMisconfigured()).toBe(false) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(POSTHOG_TOKEN_VAR)) + }) + + it('stays silent in production when the token is missing', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false') + vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', '') + vi.stubEnv('NODE_ENV', 'production') + expect(warnIfAnalyticsMisconfigured()).toBe(false) + expect(warn).not.toHaveBeenCalled() + }) + + it('stays silent on self-hosted: off is deliberate, not a misconfiguration', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true') + vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', '') + vi.stubEnv('NODE_ENV', 'development') + expect(warnIfAnalyticsMisconfigured()).toBe(false) + expect(warn).not.toHaveBeenCalled() + }) + }) +}) diff --git a/lib/analytics/__tests__/properties.test.ts b/lib/analytics/__tests__/properties.test.ts new file mode 100644 index 00000000..50a12c3e --- /dev/null +++ b/lib/analytics/__tests__/properties.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest' +import { buildPersonProperties, buildGroupProperties } from '../properties' + +describe('buildPersonProperties', () => { + it('carries email, name and role', () => { + expect( + buildPersonProperties({ + userId: 'u1', + email: 'jakob@example.se', + fullName: 'Jakob Wennberg', + role: 'owner', + }) + ).toEqual({ email: 'jakob@example.se', name: 'Jakob Wennberg', role: 'owner' }) + }) + + it('omits missing fields instead of sending nulls', () => { + expect(buildPersonProperties({ userId: 'u1', email: null, fullName: undefined })).toEqual({}) + }) + + it('never echoes the userId into properties (it is the distinct id)', () => { + const props = buildPersonProperties({ userId: 'u1', email: 'a@b.se' }) + expect(props).not.toHaveProperty('userId') + expect(props).not.toHaveProperty('user_id') + }) +}) + +describe('buildGroupProperties', () => { + const company = { + id: 'c1', + name: 'Nordvik Bygg AB', + entityType: 'aktiebolag' as const, + accountingFramework: 'k2' as const, + paysSalaries: true, + trialEndsAt: '2026-08-01', + capabilities: ['ai', 'salary'], + } + + it('carries company-shaped facts', () => { + expect(buildGroupProperties(company)).toEqual({ + name: 'Nordvik Bygg AB', + entity_type: 'aktiebolag', + accounting_framework: 'k2', + pays_salaries: true, + trial_ends_at: '2026-08-01', + capabilities: ['ai', 'salary'], + }) + }) + + // The load-bearing privacy assertion: for an enskild firma the + // organisationsnummer IS the owner's personnummer. + it('never sends org_number, whatever is passed in', () => { + const props = buildGroupProperties({ + ...company, + // @ts-expect-error deliberately passing a field the type forbids + org_number: '556677-8899', + orgNumber: '556677-8899', + }) + expect(props).not.toHaveProperty('org_number') + expect(props).not.toHaveProperty('orgNumber') + expect(JSON.stringify(props)).not.toContain('556677') + }) + + it('sorts capabilities so ordering churn is not seen as a change', () => { + const a = buildGroupProperties({ ...company, capabilities: ['salary', 'ai'] }) + const b = buildGroupProperties({ ...company, capabilities: ['ai', 'salary'] }) + expect(a.capabilities).toEqual(b.capabilities) + }) + + it('does not mutate the caller capabilities array', () => { + const capabilities = ['salary', 'ai'] + buildGroupProperties({ ...company, capabilities }) + expect(capabilities).toEqual(['salary', 'ai']) + }) + + it('omits absent optional fields', () => { + expect(buildGroupProperties({ id: 'c1', name: 'Ensam EF' })).toEqual({ name: 'Ensam EF' }) + }) +}) diff --git a/lib/analytics/enabled.ts b/lib/analytics/enabled.ts new file mode 100644 index 00000000..ade5ebd7 --- /dev/null +++ b/lib/analytics/enabled.ts @@ -0,0 +1,48 @@ +/** + * PostHog analytics gate. + * + * Analytics is a HOSTED-ONLY feature. Self-hosted deployments never load + * PostHog: an AGPL operator running their own instance should not have their + * users' behaviour shipped to our project, and the token is not baked into + * the Docker image (no `__NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN__` sentinel in + * Dockerfile / docker-entrypoint.sh, deliberately). Recapt achieved the same + * result only by accident, via that missing sentinel; here it is explicit. + * + * Mirrors the shape of `isBankIdEnabled()` in lib/auth/bankid.ts: self-hosted + * short-circuits first, then the feature's own env var decides. + */ + +export const POSTHOG_TOKEN_VAR = 'NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN' + +export function isAnalyticsEnabled(): boolean { + if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false + return Boolean(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN) +} + +/** + * A missing token must never break the app, but it must not fail silently + * either: without it every capture is dropped and the integration looks + * healthy while zero events arrive (PostHog's own framework rule). So we warn + * loudly in development and stay a no-op in production. + * + * Returns true when the caller should proceed with initialisation. + */ +export function warnIfAnalyticsMisconfigured(): boolean { + if (isAnalyticsEnabled()) return true + + // Self-hosted is a deliberate off, not a misconfiguration: stay quiet. + if (process.env.NEXT_PUBLIC_SELF_HOSTED === 'true') return false + + if (process.env.NODE_ENV === 'development') { + // console, not createLogger(): this runs in the browser from + // instrumentation-client.ts before anything else is wired, and the point + // is for a developer to see it in the devtools console immediately. + // eslint-disable-next-line no-console + console.warn( + `${POSTHOG_TOKEN_VAR} variable required by PostHog is missing or un-configured, ` + + `this causes events to be silently missed. This error stops appearing once ` + + `${POSTHOG_TOKEN_VAR} is configured` + ) + } + return false +} diff --git a/lib/analytics/posthog-observability.ts b/lib/analytics/posthog-observability.ts new file mode 100644 index 00000000..f83a1a02 --- /dev/null +++ b/lib/analytics/posthog-observability.ts @@ -0,0 +1,87 @@ +import type { ObservabilityContext, ObservabilityLevel, ObservabilitySink } from '@/lib/observability' +import { getPostHogServer, flushAnalytics } from './posthog-server' + +/** + * PostHog adapter for the provider-agnostic observability sink. + * + * Registering this is what makes `lib/observability` stop being a no-op: from + * then on every `error`-level line written through `createLogger()`, plus + * anything flagged `alert: true`, lands in PostHog Error Tracking already + * correlated with the log line's request context. That is far broader + * coverage than the `onRequestError` hook in instrumentation.ts, which only + * sees errors that escape uncaught. + * + * Redaction: `lib/observability/sink.ts` redacts both the error and the + * context before calling an adapter (and the logger has already redacted + * once; redaction is idempotent). This adapter therefore forwards what it is + * given and must never re-widen it by reaching for un-redacted sources. + * + * The interface requires captureException/captureMessage to be SYNCHRONOUS + * and to never throw. posthog-node's capture enqueues synchronously and + * sends afterwards, which fits: `flush()` is the drain the interface asks + * for, and callers on serverless paths await it. + */ + +/** PostHog Error Tracking has no severity axis, so level rides as a property. */ +function toProperties( + context: ObservabilityContext, + extra?: Record +): Record { + return { ...context, ...extra } +} + +/** + * `distinct_id` is a reserved context key set by the logger when a request + * has an identified user. Without it PostHog would attribute the event to a + * generated id per event, which fragments the error's person view. + */ +function distinctIdFrom(context: ObservabilityContext): string | undefined { + const raw = context.distinct_id ?? context.user_id ?? context.userId + return typeof raw === 'string' && raw.length > 0 ? raw : undefined +} + +export const postHogSink: ObservabilitySink = { + name: 'posthog', + + captureException(error: unknown, context: ObservabilityContext): void { + try { + const posthog = getPostHogServer() + if (!posthog) return + posthog.captureException( + error instanceof Error ? error : new Error(String(error)), + distinctIdFrom(context), + toProperties(context) + ) + } catch { + // Contract: must not throw. + } + }, + + captureMessage(message: string, level: ObservabilityLevel, context: ObservabilityContext): void { + try { + const posthog = getPostHogServer() + if (!posthog) return + const distinctId = distinctIdFrom(context) + posthog.capture({ + // PostHog requires a distinct id; fall back to a stable server marker + // rather than inventing a per-event id, which would create a new + // person for every log line. + distinctId: distinctId ?? 'server', + event: '$log', + properties: toProperties(context, { message, level }), + }) + } catch { + // Contract: must not throw. + } + }, + + async flush(): Promise { + try { + await flushAnalytics() + return true + } catch { + // Contract: must never reject. + return false + } + }, +} diff --git a/lib/analytics/posthog-server.ts b/lib/analytics/posthog-server.ts new file mode 100644 index 00000000..9b0e0f6c --- /dev/null +++ b/lib/analytics/posthog-server.ts @@ -0,0 +1,47 @@ +import 'server-only' +import { PostHog } from 'posthog-node' +import { isAnalyticsEnabled } from './enabled' + +/** + * Server-side PostHog client (posthog-node). + * + * posthog-js is browser-only, so anything captured from a route handler, a + * server component or the onRequestError hook goes through here. + * + * `flushAt: 1` / `flushInterval: 0` are NOT tuning: Vercel functions are torn + * down per invocation, and the SDK's default batching would let the process + * die before the enqueued event is sent. With these set, every capture sends + * on its own, and callers must still `await flushAnalytics()` before + * returning or the send races the teardown. + * + * Returns null when analytics is off (self-hosted, or no token), so callers + * degrade to a no-op instead of throwing. + */ +let client: PostHog | null = null + +export function getPostHogServer(): PostHog | null { + if (!isAnalyticsEnabled()) return null + if (!client) { + client = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN!, { + // Server-side traffic is not ad-blocked, so it talks to PostHog + // directly rather than through the /rl same-origin rewrite (which only + // exists in the browser's Next.js routing anyway). This is the only + // reader of NEXT_PUBLIC_POSTHOG_HOST; the EU default keeps it optional. + host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com', + flushAt: 1, + flushInterval: 0, + enableExceptionAutocapture: true, + }) + } + return client +} + +/** Await this before a request handler returns, or the send is dropped. */ +export async function flushAnalytics(): Promise { + if (!client) return + try { + await client.flush() + } catch { + // Telemetry must never fail a real request. + } +} diff --git a/lib/analytics/properties.ts b/lib/analytics/properties.ts new file mode 100644 index 00000000..bf3b836a --- /dev/null +++ b/lib/analytics/properties.ts @@ -0,0 +1,78 @@ +import type { AccountingFramework, CompanyRole, EntityType } from '@/types' + +/** + * Property builders for PostHog identify + group calls. + * + * Pure functions on purpose: the repo's Vitest runs in a `node` environment + * and `vitest.config.ts` only matches `*.test.ts`, so component JSX is + * untestable by convention. Keeping the property shaping here means the part + * that can actually be wrong (what we send about a person and a company) is + * covered by unit tests, while the mounting component stays trivial. + * + * Two rules encoded here, both from PostHog's framework guidance: + * + * 1. PII belongs in identify() PERSON properties, never in capture() event + * properties. Only `buildPersonProperties` carries email/name. + * 2. `org_number` is deliberately NEVER sent. For an enskild firma the + * organisationsnummer IS the owner's personnummer, so it is not a company + * identifier at all: it is a national identity number for a natural + * person. `company_id` is the group key and is already unique. + */ + +export interface AnalyticsUserInput { + userId: string + email?: string | null + fullName?: string | null + role?: CompanyRole | null +} + +export interface AnalyticsCompanyInput { + id: string + name: string + entityType?: EntityType | null + accountingFramework?: AccountingFramework | null + paysSalaries?: boolean | null + trialEndsAt?: string | null + capabilities?: readonly string[] +} + +/** Drop null/undefined so PostHog person properties don't fill with empties. */ +function compact(input: Record): Record { + return Object.fromEntries( + Object.entries(input).filter(([, value]) => value !== null && value !== undefined) + ) +} + +/** + * Person properties for `posthog.identify(userId, props)`. + * + * Matches what the privacy policy discloses is transferred: user id, email + * address and company name. Do not widen this without updating + * app/(public)/privacy/page.tsx and .compliance/ropa.yaml. + */ +export function buildPersonProperties(user: AnalyticsUserInput): Record { + return compact({ + email: user.email, + name: user.fullName, + role: user.role, + }) +} + +/** + * Group properties for `posthog.group('company', companyId, props)`. + * + * Company-shaped facts only: anything that varies per user (role) belongs on + * the person, not the group. No org_number, see the file header. + */ +export function buildGroupProperties(company: AnalyticsCompanyInput): Record { + return compact({ + name: company.name, + entity_type: company.entityType, + accounting_framework: company.accountingFramework, + pays_salaries: company.paysSalaries, + trial_ends_at: company.trialEndsAt, + // Sorted so the same entitlement set doesn't look like a change every + // time the underlying query returns a different order. + capabilities: company.capabilities ? [...company.capabilities].sort() : undefined, + }) +} diff --git a/lib/analytics/reset.ts b/lib/analytics/reset.ts new file mode 100644 index 00000000..a3aa77c8 --- /dev/null +++ b/lib/analytics/reset.ts @@ -0,0 +1,27 @@ +import posthog from 'posthog-js' +import { isAnalyticsEnabled } from './enabled' + +/** + * Detach the current person from PostHog on logout. + * + * Call this ONLY on the transition out of an identified session, never on an + * initially anonymous page load: reset() discards the anonymous distinct id + * and the history attached to it, so a stray call at boot would sever the + * pre-login part of a signup funnel. + * + * Replaces clearRecaptIdentity() from lib/recapt.ts. That helper also had to + * sweep localStorage by key prefix, because Recapt cached the uid there. We + * run with `persistence: 'memory'`, so there is no cached identity to wipe: + * reset() is sufficient. (Surveys' own `seenSurvey_*` flags are deliberately + * left alone: they carry no identity, only "this browser already saw this + * survey", and clearing them would re-prompt the next person on the device.) + */ +export function resetAnalyticsIdentity(): void { + if (typeof window === 'undefined') return + if (!isAnalyticsEnabled()) return + try { + posthog.reset() + } catch { + // best-effort: we're already in a logout flow + } +} diff --git a/lib/init.ts b/lib/init.ts index 647b5190..4fbaa588 100644 --- a/lib/init.ts +++ b/lib/init.ts @@ -4,6 +4,9 @@ import { createExtensionContext } from '@/lib/extensions/context-factory' import { registerSupplierInvoiceHandler } from '@/lib/bookkeeping/handlers/supplier-invoice-handler' import { registerEventLogHandler } from '@/lib/events/handlers/event-log-handler' import { registerWebhookHandler } from '@/lib/webhooks/handler' +import { registerObservabilitySink } from '@/lib/observability' +import { postHogSink } from '@/lib/analytics/posthog-observability' +import { isAnalyticsEnabled } from '@/lib/analytics/enabled' import { createLogger } from '@/lib/logger' const log = createLogger('init') @@ -70,6 +73,13 @@ export function ensureInitialized(): void { validateEnvironment() setContextFactory(createExtensionContext) + // Turns lib/observability from a no-op into PostHog Error Tracking. Gated, + // so with no token (core, CI, self-hosted) the sink stays the no-op and + // PostHog is never constructed and never contacted. Note the SDK is still + // BUNDLED in those builds: the imports are static, so the bytes ship even + // though nothing initialises. Making that a true zero would mean dynamic + // imports at every posthog call site, which is a deliberate non-goal here. + if (isAnalyticsEnabled()) registerObservabilitySink(postHogSink) registerSupplierInvoiceHandler() registerEventLogHandler() registerWebhookHandler() diff --git a/next.config.ts b/next.config.ts index 28c796c4..828b8a7c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -65,9 +65,42 @@ const nextConfig: NextConfig = { turbopack: { root: projectRoot, }, + // PostHog sends trailing-slash API requests; without this Next 308s them + // and the events are lost. Required by the reverse proxy below. + skipTrailingSlashRedirect: true, experimental: { optimizePackageImports: ['recharts', 'date-fns', 'framer-motion'], }, + // PostHog reverse proxy. Keeping analytics same-origin buys three things: + // the strict CSP below needs NO posthog hosts (`connect-src 'self'` already + // covers ingestion, `script-src 'self'` the lazy-loaded replay/survey + // bundles), tracking blockers have no third-party host to match, and the + // Recapt host allowlist is replaced by nothing at all. + // + // `/rl` is deliberately meaningless: PostHog's own guidance is that obvious + // prefixes (/analytics, /tracking, /telemetry, /posthog, and increasingly + // /ingest) are on blocker filter lists. It must stay in sync with `api_host` + // in instrumentation-client.ts AND with the matcher exclusion in proxy.ts, + // or middleware redirects the ingestion POSTs to /login. + // + // Both /static/* and /array/* must point at the ASSETS origin, not the + // ingestion origin: array/ serves the config bundle and is easy to miss. + async rewrites() { + return [ + { + source: '/rl/static/:path*', + destination: 'https://eu-assets.i.posthog.com/static/:path*', + }, + { + source: '/rl/array/:path*', + destination: 'https://eu-assets.i.posthog.com/array/:path*', + }, + { + source: '/rl/:path*', + destination: 'https://eu.i.posthog.com/:path*', + }, + ] + }, async redirects() { const appUrlForRedirect = process.env.NEXT_PUBLIC_APP_URL?.trim().replace(/\/$/, '') return [ diff --git a/package-lock.json b/package-lock.json index 27249aee..892a3a94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,8 @@ "next-intl": "^4.13.2", "next-themes": "^0.4.6", "pdf-lib": "^1.17.1", + "posthog-js": "^1.407.3", + "posthog-node": "^5.46.1", "qrcode": "^1.5.4", "react": "19.2.7", "react-dom": "19.2.7", @@ -3784,6 +3786,31 @@ "pako": "^1.0.10" } }, + "node_modules/@posthog/browser-common": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.2.2.tgz", + "integrity": "sha512-4NHumu2lx7pMeucDLfXnDmeP5CV2oVWY8jBpXBwBUjoYwkQxB7Z3A6q9oDydw+/iSAGl6MaPqW9gsnRK8AvqLA==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.45.1", + "@posthog/types": "^1.398.0" + } + }, + "node_modules/@posthog/core": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.45.1.tgz", + "integrity": "sha512-tLtvzomavb2PPWdGYKsusyIzIeL2Px47v348Smibkay7sMy/83TyPk+Ptsp2NdeOgJsbuwSxWkR2+XA0aSCAaA==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.398.0" + } + }, + "node_modules/@posthog/types": { + "version": "1.398.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.398.0.tgz", + "integrity": "sha512-sJMkl4k+u8yS/0fjHsKqE9xTdsAh30a2WvgChiptellnVoE0e8QJKFgqOMD2sk8FaEArPdeFklAhXvmENAt3Sg==", + "license": "MIT" + }, "node_modules/@radix-ui/number": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", @@ -7720,6 +7747,13 @@ "sharp": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -9283,6 +9317,17 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -9714,6 +9759,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -14318,6 +14372,67 @@ "node": ">=0.10.0" } }, + "node_modules/posthog-js": { + "version": "1.407.3", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.407.3.tgz", + "integrity": "sha512-b9Kc7HVN6nh+xsh1JrcLYHv9bbYLwYUM9belJtNVUFZSJWWfFcZRJJP6L+KeanAeu2VxSFSpioVA39pjjolv4A==", + "license": "(Apache-2.0 AND MIT)", + "dependencies": { + "@posthog/browser-common": "^0.2.2", + "@posthog/core": "^1.45.1", + "@posthog/types": "^1.398.0", + "core-js": "^3.49.0", + "dompurify": "^3.3.2", + "fflate": "^0.4.8", + "preact": "^10.29.3", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^5.3.0" + } + }, + "node_modules/posthog-js/node_modules/fflate": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", + "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", + "license": "MIT" + }, + "node_modules/posthog-node": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.46.1.tgz", + "integrity": "sha512-WjCqExq44pBdyg9MSsH6UAE0tNZ88p4aIuVFicgqhjf2Fbws6IhS4ioYUa4aBrbUPS9EDRXtBTtF5DpP1ml8Pw==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.45.1" + }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } + } + }, + "node_modules/preact": { + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -14397,6 +14512,12 @@ "node": ">=10.13.0" } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -16891,6 +17012,12 @@ "node": ">= 16" } }, + "node_modules/web-vitals": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz", + "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==", + "license": "Apache-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index a639a149..fb793512 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,8 @@ "next-intl": "^4.13.2", "next-themes": "^0.4.6", "pdf-lib": "^1.17.1", + "posthog-js": "^1.407.3", + "posthog-node": "^5.46.1", "qrcode": "^1.5.4", "react": "19.2.7", "react-dom": "19.2.7", diff --git a/proxy.ts b/proxy.ts index 28fbf485..257c895e 100644 --- a/proxy.ts +++ b/proxy.ts @@ -17,7 +17,15 @@ export const config = { * NOTE: `/api` is intentionally INCLUDED so the proxy can enforce the MFA * (AAL2) gate on cookie-authenticated API calls (updateSession short- * circuits API routes after that check: see lib/supabase/middleware.ts). + * + * `/rl` is the PostHog reverse-proxy prefix (rewrites in next.config.ts). + * It MUST be excluded: middleware runs BEFORE next.config rewrites, so + * without this updateSession() treats an ingestion POST as an unknown + * protected path and 307s it to /login. That silently kills analytics on + * every logged-out page and, because flags and asset loads still succeed + * through the rewrite, the integration looks healthy while no events + * arrive. Keep in sync with `api_host` in instrumentation-client.ts. */ - '/((?!_next/static|_next/image|favicon.ico|\\.well-known|sw\\.js|sw-register\\.js|manifest\\.json|manifest\\.webmanifest|icons/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|js|json)$).*)', + '/((?!_next/static|_next/image|favicon.ico|\\.well-known|rl/|sw\\.js|sw-register\\.js|manifest\\.json|manifest\\.webmanifest|icons/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|js|json)$).*)', ], } diff --git a/tests/stubs/server-only.ts b/tests/stubs/server-only.ts new file mode 100644 index 00000000..b4542714 --- /dev/null +++ b/tests/stubs/server-only.ts @@ -0,0 +1,14 @@ +/** + * Test stub for the `server-only` package. + * + * `server-only` is a Next.js BUILD-time guard: its real entry point throws + * unconditionally, and the bundler swaps in a harmless module for the server + * graph. Vitest has no such bundler step, so any module that imports it + * (lib/analytics/posthog-server.ts, app/(dashboard)/request-context.ts) + * explodes with "This module cannot be imported from a Client Component + * module" the moment a test touches it, directly or transitively. + * + * Aliasing it to this empty module in vitest.config.ts keeps the guard doing + * its real job in `next build` while letting the suite import server modules. + */ +export {} diff --git a/vitest.config.ts b/vitest.config.ts index 031d8771..5a02c730 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,13 @@ import { defineConfig } from 'vitest/config' import path from 'path' -const alias = { '@': path.resolve(__dirname, '.') } +const alias = { + '@': path.resolve(__dirname, '.'), + // `server-only` is a build-time guard whose real entry point always throws; + // Next.js swaps it out during bundling, Vitest cannot. Without this stub any + // test that transitively imports a server-only module fails at import time. + 'server-only': path.resolve(__dirname, 'tests/stubs/server-only.ts'), +} const unitProject = { resolve: { alias },