feat(analytics): remove Recapt, PostHog is now the only analytics (#1238)

* feat(analytics): remove Recapt, PostHog is now the only analytics

Recapt shuts down in days. Everything it did is covered by the PostHog
integration in the previous commit, so the SDK, its five modules and its
CSP hosts come out.

Deleted: RecaptLoader, RecaptHideWidget, RecaptIdentify, lib/recapt.ts,
types/recapt.d.ts. Unmounted from app/layout.tsx (the <script> in <head>
and the widget-hider) and from app/(dashboard)/layout.tsx. Both logout
handlers already call resetAnalyticsIdentity() and now only that.

The CSP gets strictly narrower: connect-src loses api.recapt.app and
cdn.recapt.app, script-src loses cdn.recapt.app, and nothing is added in
their place, because PostHog runs through the same-origin /rl rewrite.
Verified against the built routes-manifest.

Behaviour change worth calling out: lib/support/submit-feedback.ts is now
single-channel. Recapt used to accept the message through its own SDK, so
a failing /api/support/contact still reported success to the user. Email
is now the only delivery path and its failure is visible. That is the
right outcome, silently "succeeding" while the message reached nobody was
worse, and the Resend path is solid. A non-blocking
posthog.capture('support_feedback_submitted') keeps the useful half of
the old dual-channel behaviour by putting the submission on the user's
timeline next to the session replay; it carries no message body, since
free text is user content and would be PII in an event property. The six
Recapt-specific test cases are replaced with the email-only contract plus
coverage of the breadcrumb, the self-hosted skip, and a throwing SDK not
breaking delivery.

Compliance, which Recapt never had: the privacy page sub-processor row is
replaced (not just deleted) with an accurate PostHog row, and .compliance/
ropa.yaml gains a product.analytics activity. The old row also claimed
Recapt loaded "endast for inloggade anvandare", which was never true,
RecaptLoader sat in the root <head> on every page including logged-out
ones. The new row describes what actually happens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(analytics): purge Recapt storage left on users' devices

Removing the Recapt <script> stops it writing anything new, but every
browser that already loaded the app keeps what it persisted. Observed on
production after #1237: localStorage still holds
`__recapt_record_engine`, and after this PR nothing would ever remove it,
because the helper that used to sweep on logout (lib/recapt.ts
clearRecaptIdentity) is deleted along with the SDK.

Inert data, but it is third-party storage from a processor the privacy
page now says we no longer use, and the whole point of the PostHog
config is that nothing is stored on the device. So clear it.

Matching is by substring rather than prefix on purpose: the old sweep
tested key.startsWith('recapt'), which never actually matched the real
key, since `__recapt_record_engine` starts with underscores. A test pins
that. The app's own keys (Accounted:chat-sidebar-collapsed,
gnubok.inbox.onboarding.dismissed) contain neither marker.

Runs unconditionally from instrumentation-client.ts, before the
analytics gate, so a browser gets cleaned even on a build where PostHog
is switched off. Iterates backwards because removeItem() re-indexes the
store and a forward loop would skip entries; both covered by tests, along
with private-mode throws and the server no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-27 15:08:32 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent c62d00bcb3
commit 248d98bd7e
18 changed files with 337 additions and 269 deletions
@@ -0,0 +1,99 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { purgeLegacyAnalyticsStorage } from '../purge-legacy-storage'
/** Minimal in-memory Storage stand-in: the suite runs in a node env. */
function makeStorage(initial: Record<string, string> = {}): Storage {
const map = new Map(Object.entries(initial))
return {
get length() {
return map.size
},
key: (i: number) => [...map.keys()][i] ?? null,
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
clear: () => map.clear(),
} as unknown as Storage
}
function keysOf(s: Storage): string[] {
return Array.from({ length: s.length }, (_, i) => s.key(i)!).sort()
}
describe('purgeLegacyAnalyticsStorage', () => {
afterEach(() => vi.unstubAllGlobals())
it('removes the real key observed in production', () => {
const local = makeStorage({ __recapt_record_engine: 'x' })
vi.stubGlobal('window', { localStorage: local, sessionStorage: makeStorage() })
expect(purgeLegacyAnalyticsStorage()).toBe(1)
expect(keysOf(local)).toEqual([])
})
// The helper this replaces used startsWith('recapt'), which never matched
// `__recapt_record_engine`. Pin the substring behaviour so it cannot regress.
it('matches by substring, not prefix', () => {
const local = makeStorage({
__recapt_record_engine: 'a',
'ph_glimt_session': 'b',
'RECAPT_UPPER': 'c',
})
vi.stubGlobal('window', { localStorage: local, sessionStorage: makeStorage() })
expect(purgeLegacyAnalyticsStorage()).toBe(3)
expect(keysOf(local)).toEqual([])
})
it("leaves the app's own keys alone", () => {
const local = makeStorage({
'Accounted:chat-sidebar-collapsed': '1',
'gnubok.inbox.onboarding.dismissed': '1',
__recapt_record_engine: 'x',
})
vi.stubGlobal('window', { localStorage: local, sessionStorage: makeStorage() })
expect(purgeLegacyAnalyticsStorage()).toBe(1)
expect(keysOf(local)).toEqual([
'Accounted:chat-sidebar-collapsed',
'gnubok.inbox.onboarding.dismissed',
])
})
it('sweeps sessionStorage too', () => {
const session = makeStorage({ glimt_buffer: 'x' })
vi.stubGlobal('window', { localStorage: makeStorage(), sessionStorage: session })
expect(purgeLegacyAnalyticsStorage()).toBe(1)
expect(keysOf(session)).toEqual([])
})
// Backwards iteration matters: removeItem() re-indexes, so a forward loop
// skips the entry after each removal.
it('removes every match even when they are adjacent', () => {
const local = makeStorage({ recapt_a: '1', recapt_b: '2', recapt_c: '3', keep: '4' })
vi.stubGlobal('window', { localStorage: local, sessionStorage: makeStorage() })
expect(purgeLegacyAnalyticsStorage()).toBe(3)
expect(keysOf(local)).toEqual(['keep'])
})
it('is a no-op on a second run', () => {
const local = makeStorage({ __recapt_record_engine: 'x' })
vi.stubGlobal('window', { localStorage: local, sessionStorage: makeStorage() })
purgeLegacyAnalyticsStorage()
expect(purgeLegacyAnalyticsStorage()).toBe(0)
})
it('never throws when storage is unavailable (private mode)', () => {
vi.stubGlobal('window', {
get localStorage(): Storage {
throw new Error('SecurityError')
},
get sessionStorage(): Storage {
throw new Error('SecurityError')
},
})
expect(() => purgeLegacyAnalyticsStorage()).not.toThrow()
})
it('returns 0 on the server', () => {
vi.stubGlobal('window', undefined)
expect(purgeLegacyAnalyticsStorage()).toBe(0)
})
})
+55
View File
@@ -0,0 +1,55 @@
/**
* One-time cleanup of storage left behind by Recapt.
*
* Removing the Recapt <script> stops it writing anything NEW, but every
* browser that has already loaded the app keeps whatever Recapt persisted:
* observed in production as `__recapt_record_engine` in localStorage. Nothing
* would ever remove it, because the helper that used to sweep on logout
* (lib/recapt.ts `clearRecaptIdentity`) is deleted along with the SDK.
*
* That leftover is inert, but it is third-party storage from a processor we
* have told users we no longer use (app/(public)/privacy/page.tsx), and this
* app's whole analytics posture is "nothing on the device". So we clear it.
*
* Matching is by SUBSTRING, not prefix, on purpose. The old sweep tested
* `key.startsWith('recapt')`, which never actually matched the real key:
* `__recapt_record_engine` starts with underscores. The app's own keys
* (`Accounted:chat-sidebar-collapsed`, `gnubok.inbox.onboarding.dismissed`)
* contain neither token, so there is nothing to collide with.
*
* Safe to call on every load: once the keys are gone the loop finds nothing
* and the whole thing costs one localStorage.length read.
*/
const LEGACY_MARKERS = ['recapt', 'glimt']
function purgeFrom(store: Storage): number {
let removed = 0
// Iterate backwards: removeItem() re-indexes the store, so a forward loop
// skips the entry after each removal.
for (let i = store.length - 1; i >= 0; i--) {
const key = store.key(i)
if (!key) continue
const lower = key.toLowerCase()
if (LEGACY_MARKERS.some((m) => lower.includes(m))) {
store.removeItem(key)
removed++
}
}
return removed
}
export function purgeLegacyAnalyticsStorage(): number {
if (typeof window === 'undefined') return 0
let removed = 0
try {
removed += purgeFrom(window.localStorage)
} catch {
// Storage can throw in private mode / when disabled: never break boot.
}
try {
removed += purgeFrom(window.sessionStorage)
} catch {
// Same.
}
return removed
}
-31
View File
@@ -1,31 +0,0 @@
// Recapt's identify SDK keeps the last-known uid in memory and in
// localStorage. Passing `uid: undefined` is not a documented logout
// signal: on some SDK versions it's coerced to the previous value.
// We send an explicit empty-string uid (the SDK's "anonymous" marker),
// then clear any persisted Recapt keys from localStorage so the next
// pageload doesn't re-identify the logged-out user from cache.
export function clearRecaptIdentity(): void {
if (typeof window === 'undefined') return
try {
if (typeof window.recapt === 'function') {
window.recapt('identify', {
uid: '',
email: undefined,
nickname: undefined,
})
}
// Defense-in-depth: wipe any Recapt-namespaced storage on logout so
// a shared device cannot resurrect the previous user's identity on
// the next page load.
if (typeof window.localStorage !== 'undefined') {
for (let i = window.localStorage.length - 1; i >= 0; i--) {
const key = window.localStorage.key(i)
if (key && (key.startsWith('recapt') || key.startsWith('glimt'))) {
window.localStorage.removeItem(key)
}
}
}
} catch {
// best-effort: we're already in a logout flow
}
}
+64 -66
View File
@@ -1,40 +1,40 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { submitFeedback } from '@/lib/support/submit-feedback'
// posthog-js is browser-only and irrelevant to delivery: stub it so the
// analytics breadcrumb can be asserted without initialising the real SDK.
const captureMock = vi.fn()
vi.mock('posthog-js', () => ({ default: { capture: (...a: unknown[]) => captureMock(...a) } }))
describe('submitFeedback', () => {
beforeEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
vi.restoreAllMocks()
captureMock.mockClear()
// Analytics on by default so the breadcrumb path is exercised.
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', 'phc_test')
})
afterEach(() => {
vi.unstubAllGlobals()
vi.unstubAllEnvs()
})
function stubRecapt(impl: (...args: unknown[]) => void) {
vi.stubGlobal('window', { recapt: impl })
}
function stubNoRecapt() {
vi.stubGlobal('window', {})
}
function stubFetchOk() {
const fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
vi.stubGlobal('fetch', fetchSpy)
return fetchSpy
}
it('sends to both Recapt and email when SDK is present', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
it('delivers over email and reports the email channel', async () => {
const fetchSpy = stubFetchOk()
const result = await submitFeedback({ subject: 'Hjälpsida', message: 'Hjälp tack' })
expect(result.ok).toBe(true)
expect(result.channels.sort()).toEqual(['email', 'recapt'])
expect(recapt).toHaveBeenCalledWith('feedback', { message: '[Hjälpsida]\n\nHjälp tack' })
expect(result.channels).toEqual(['email'])
expect(fetchSpy).toHaveBeenCalledWith(
'/api/support/contact',
expect.objectContaining({
@@ -44,57 +44,10 @@ describe('submitFeedback', () => {
)
})
it('omits subject prefix in Recapt payload when subject not provided', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
stubFetchOk()
await submitFeedback({ message: 'plain' })
expect(recapt).toHaveBeenCalledWith('feedback', { message: 'plain' })
})
it('still reports success via email when Recapt throws', async () => {
stubRecapt(() => {
throw new Error('boom')
})
stubFetchOk()
const result = await submitFeedback({ subject: 'X', message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
})
it('uses email only when Recapt SDK is absent', async () => {
stubNoRecapt()
const fetchSpy = stubFetchOk()
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
expect(fetchSpy).toHaveBeenCalledOnce()
})
it('reports success when Recapt succeeds even if email fails', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: async () => ({ error: 'down' }) })
)
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['recapt'])
})
it('returns failure with email error when both channels fail', async () => {
stubRecapt(() => {
throw new Error('boom')
})
// Recapt used to mask a failing email endpoint by reporting success on its
// own channel. Email is now the only delivery path, so its failure must
// surface to the user instead of being swallowed.
it('reports failure when the email endpoint rejects', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
@@ -110,8 +63,7 @@ describe('submitFeedback', () => {
expect(result.error).toBe('Mailtjänsten är inte konfigurerad')
})
it('returns failure when fetch itself throws and Recapt is absent', async () => {
stubNoRecapt()
it('reports failure when fetch itself throws', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network down')))
const result = await submitFeedback({ message: 'msg' })
@@ -120,4 +72,50 @@ describe('submitFeedback', () => {
expect(result.channels).toEqual([])
expect(result.error).toBe('Network down')
})
it('records a PostHog breadcrumb WITHOUT the message body', async () => {
stubFetchOk()
await submitFeedback({ subject: 'Hjälpsida', message: 'känslig text om mitt bolag' })
expect(captureMock).toHaveBeenCalledWith('support_feedback_submitted', {
subject: 'Hjälpsida',
delivered: true,
})
// Free text is user content: it must never ride along as an event property.
expect(JSON.stringify(captureMock.mock.calls)).not.toContain('känslig text')
})
it('marks the breadcrumb undelivered when email failed', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) }))
await submitFeedback({ message: 'msg' })
expect(captureMock).toHaveBeenCalledWith(
'support_feedback_submitted',
expect.objectContaining({ delivered: false })
)
})
it('skips the breadcrumb entirely when analytics is off (self-hosted)', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
stubFetchOk()
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(captureMock).not.toHaveBeenCalled()
})
it('does not let a throwing analytics SDK break delivery', async () => {
captureMock.mockImplementationOnce(() => {
throw new Error('posthog boom')
})
stubFetchOk()
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
})
})
+30 -22
View File
@@ -1,9 +1,19 @@
import posthog from 'posthog-js'
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
export interface SubmitFeedbackInput {
message: string
subject?: string
}
export type SupportChannel = 'recapt' | 'email'
/**
* Delivery channels. Recapt used to be a second one: it accepted the message
* through its feedback SDK, so a failing /api/support/contact still reported
* success. With Recapt gone, email is the only delivery channel and its
* failure is now a real, visible failure. That is correct: silently
* "succeeding" while the message reached nobody was the worse behaviour.
*/
export type SupportChannel = 'email'
export interface SubmitFeedbackResult {
ok: boolean
@@ -11,11 +21,6 @@ export interface SubmitFeedbackResult {
error?: string
}
function composeMessage({ message, subject }: SubmitFeedbackInput): string {
if (!subject) return message
return `[${subject}]\n\n${message}`
}
async function submitViaEmail(
{ message, subject }: SubmitFeedbackInput
): Promise<{ ok: true } | { ok: false; error: string }> {
@@ -35,34 +40,37 @@ async function submitViaEmail(
}
}
function submitViaRecapt(
input: SubmitFeedbackInput
): { ok: true } | { ok: false; error: string } | null {
const recapt = typeof window !== 'undefined' ? window.recapt : undefined
if (typeof recapt !== 'function') return null
/**
* Breadcrumb on the user's PostHog timeline so a support message is visible
* next to the session replay that led to it: the genuinely useful half of what
* the Recapt channel provided. NOT a delivery channel, and deliberately
* carries no message body: free text is user content and would be PII in an
* event property. Email remains the only thing that actually delivers.
*/
function noteInAnalytics({ subject }: SubmitFeedbackInput, delivered: boolean): void {
if (!isAnalyticsEnabled()) return
try {
recapt('feedback', { message: composeMessage(input) })
return { ok: true }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Recapt-fel' }
posthog.capture('support_feedback_submitted', {
subject: subject ?? null,
delivered,
})
} catch {
// Telemetry must never affect whether the user's message went out.
}
}
export async function submitFeedback(input: SubmitFeedbackInput): Promise<SubmitFeedbackResult> {
const recaptResult = submitViaRecapt(input)
const emailResult = await submitViaEmail(input)
const channels: SupportChannel[] = []
if (recaptResult?.ok) channels.push('recapt')
if (emailResult.ok) channels.push('email')
noteInAnalytics(input, emailResult.ok)
if (channels.length > 0) {
return { ok: true, channels }
if (emailResult.ok) {
return { ok: true, channels: ['email'] }
}
return {
ok: false,
channels: [],
error: emailResult.ok ? undefined : emailResult.error,
error: emailResult.error,
}
}