Files
accounted/instrumentation-client.ts
T
Jakob WennbergandClaude Opus 5 d4f82cafc4 feat(analytics): add PostHog (EU) behind a same-origin proxy (#1237)
Recapt shuts down in four days, taking product analytics and session
replay with it. This adds PostHog Cloud EU alongside it; the Recapt
removal follows separately so events can be confirmed landing first.

Wiring choices that are not the tutorial defaults:

- Same-origin reverse proxy (/rl -> eu.i.posthog.com) instead of adding
  PostHog hosts to the CSP. connect-src 'self' and script-src 'self'
  already cover it, tracking blockers have no third-party host to match,
  and the Recapt allowlist entries in next.config.ts get replaced by
  nothing at all when they go. Needs skipTrailingSlashRedirect, since
  PostHog sends trailing-slash API requests; verified that trailing-slash
  URLs on normal routes still resolve 200 rather than 404.

- /rl is excluded from the proxy.ts matcher. Middleware runs BEFORE
  next.config rewrites, so without this updateSession() treats an
  ingestion POST as an unknown protected path and 307s it to /login.
  Verified with a control: /zz/flags/ -> 307 /login, /rl/flags/ -> 200
  from PostHog. This fails silently otherwise, because asset loads keep
  working through the rewrite while no events arrive.

- persistence: 'memory' so nothing is written to the device and no
  cookie-consent banner is required. Everything post-login is unaffected:
  AnalyticsIdentify re-identifies on each dashboard load.

- session_recording.maskTextSelector: '*'. PostHog masks inputs but not
  text by default, and this app renders org numbers (which for an
  enskild firma ARE the owner's personnummer), customer names and
  balances as ordinary text. Replays show where a user gets stuck, never
  what their books say. buildGroupProperties() also refuses to send
  org_number at all, with a test pinning it.

- Error tracking registers through the existing lib/observability sink
  rather than bypassing it, so every error-level createLogger() line is
  captured already redacted. instrumentation.ts onRequestError covers
  what escapes uncaught.

Analytics is hosted-only: isAnalyticsEnabled() short-circuits on
NEXT_PUBLIC_SELF_HOSTED and no Docker sentinel is added, so self-hosted
runs with zero third-party runtime code. Recapt got that outcome only by
accident, via a missing sentinel; here it is explicit and tested.

vitest.config.ts aliases 'server-only' to a stub: it is a build-time
guard whose real entry point always throws, which broke 48 test files the
moment a server-only module entered the graph. request-context.ts was
already carrying the same latent trap.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 14:30:49 +02:00

75 lines
3.2 KiB
TypeScript

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
* <PostHogProvider> 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',
})
}