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>
This commit is contained in:
+20
-4
@@ -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.
|
||||
|
||||
+6
-1
@@ -95,4 +95,9 @@ scripts/reopen-bokslut.sql
|
||||
.claude/plans/write-up-a-plan-streamed-fiddle.md
|
||||
/ingaende-balanser-test.csv
|
||||
.agents
|
||||
.codex
|
||||
.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/
|
||||
|
||||
@@ -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 && (
|
||||
<AnalyticsIdentify
|
||||
user={{
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
fullName: userProfile?.full_name ?? null,
|
||||
role: memberRow.role as CompanyRole,
|
||||
}}
|
||||
company={{
|
||||
id: companyId,
|
||||
name: displayName,
|
||||
entityType,
|
||||
accountingFramework: companyRow.accounting_framework as AccountingFramework,
|
||||
paysSalaries,
|
||||
trialEndsAt: entitlements.trialEndsAt,
|
||||
capabilities: entitlements.capabilities,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AgentSheetProvider>
|
||||
</CompanyProvider>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* <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',
|
||||
})
|
||||
}
|
||||
@@ -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<string, string | string[] | undefined> | 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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
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<boolean> {
|
||||
try {
|
||||
await flushAnalytics()
|
||||
return true
|
||||
} catch {
|
||||
// Contract: must never reject.
|
||||
return false
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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<void> {
|
||||
if (!client) return
|
||||
try {
|
||||
await client.flush()
|
||||
} catch {
|
||||
// Telemetry must never fail a real request.
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+10
@@ -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()
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Generated
+127
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)$).*)',
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
+7
-1
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user