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:
co-authored by
Claude Opus 5
parent
c62d00bcb3
commit
248d98bd7e
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user