From 829b07a0dbd7d11fa1c0568a17a2ae49254d79d0 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 23 Oct 2026 23:56:33 +0200 Subject: [PATCH] fix(analytics): capture posthog events only on the production host PostHog received events from localhost, 127.0.0.1, the 8787 preview and workers.dev previews: the SDK was enabled on every build and the consent layer opted in by default. Off the production host (siteUrl from the production runtime config, so dev and preview builds have none) a base plugin opts PostHog out, covering pageview, autocapture, pageleave and recording. The consent controller never opts in there, including from a stored choice. Production behaviour is unchanged. Claude-Session: https://claude.ai/code/session_01Px57iMQNzBFqJ4Y9aZf8Dt --- .../plugins/posthog-production-gate.client.ts | 12 +++++ layers/base/utils/analyticsHost.ts | 14 ++++++ .../consent/composables/useCookieConsent.ts | 5 +- layers/consent/utils/consentController.ts | 8 ++- tests/analytics/analytics-host.spec.ts | 50 +++++++++++++++++++ tests/consent/controller.spec.ts | 29 ++++++++++- 6 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 layers/base/plugins/posthog-production-gate.client.ts create mode 100644 layers/base/utils/analyticsHost.ts create mode 100644 tests/analytics/analytics-host.spec.ts diff --git a/layers/base/plugins/posthog-production-gate.client.ts b/layers/base/plugins/posthog-production-gate.client.ts new file mode 100644 index 00000000..d138e993 --- /dev/null +++ b/layers/base/plugins/posthog-production-gate.client.ts @@ -0,0 +1,12 @@ +// Runs after nuxt-posthog's plugin, which creates $clientPosthog. Opting out here +// silences pageview, autocapture, pageleave and recording on every non-production host. +export default defineNuxtPlugin({ + name: 'posthog-production-gate', + dependsOn: ['posthog'], + setup() { + const { $clientPosthog } = useNuxtApp() + if (!$clientPosthog) return + const production = productionHostname(useRuntimeConfig().public.siteUrl) + if (!isAnalyticsHost(window.location.hostname, production)) $clientPosthog.opt_out_capturing() + }, +}) diff --git a/layers/base/utils/analyticsHost.ts b/layers/base/utils/analyticsHost.ts new file mode 100644 index 00000000..f2f4322c --- /dev/null +++ b/layers/base/utils/analyticsHost.ts @@ -0,0 +1,14 @@ +/** Production hostname. Undefined off production: only the production build sets siteUrl. */ +export function productionHostname(siteUrl: string | undefined): string | undefined { + if (!siteUrl) return undefined + try { + return new URL(siteUrl).hostname + } catch { + return undefined + } +} + +/** PostHog captures only on the production host; localhost and previews stay silent. */ +export function isAnalyticsHost(hostname: string, productionHost: string | undefined): boolean { + return !!productionHost && hostname === productionHost +} diff --git a/layers/consent/composables/useCookieConsent.ts b/layers/consent/composables/useCookieConsent.ts index b8941dd3..d44b2ea6 100644 --- a/layers/consent/composables/useCookieConsent.ts +++ b/layers/consent/composables/useCookieConsent.ts @@ -1,4 +1,4 @@ -import { computed, useNuxtApp, useState } from '#imports' +import { computed, useNuxtApp, useRuntimeConfig, useState } from '#imports' import type { ConsentState } from '../types' import { consentCookieString, readConsentCookie } from '../utils/consentCookie' import { analyticsAllowed, createConsentController, isBannerVisible } from '../utils/consentController' @@ -25,6 +25,9 @@ export function useCookieConsent() { document.cookie = consentCookieString(next, location.protocol === 'https:') }, applyAnalytics: (allowed) => applyPostHog(nuxtApp.$clientPosthog, allowed), + // Client only: the SSR render has no location, and never reaches applyAnalytics. + analyticsHost: import.meta.client + && isAnalyticsHost(location.hostname, productionHostname(useRuntimeConfig().public.siteUrl)), }) return { diff --git a/layers/consent/utils/consentController.ts b/layers/consent/utils/consentController.ts index 6094415f..b76cc3df 100644 --- a/layers/consent/utils/consentController.ts +++ b/layers/consent/utils/consentController.ts @@ -11,15 +11,19 @@ export interface ConsentPorts { setBannerOpen: (open: boolean) => void persist: (state: ConsentState) => void applyAnalytics: (allowed: boolean) => void + /** False off the production host: a stored opt-in must never switch capture on there. */ + analyticsHost: boolean } export function createConsentController(ports: ConsentPorts) { + const applyAnalytics = (allowed: boolean) => ports.applyAnalytics(allowed && ports.analyticsHost) + // A decision always closes the banner, including one reopened from the footer. function decide(analytics: boolean) { const state: ConsentState = { analytics, decidedAt: ports.now().toISOString() } ports.setState(state) ports.persist(state) - ports.applyAnalytics(analytics) + applyAnalytics(analytics) ports.setBannerOpen(false) } @@ -32,7 +36,7 @@ export function createConsentController(ports: ConsentPorts) { restore(stored: ConsentState | null) { if (!stored) return ports.setState(stored) - ports.applyAnalytics(stored.analytics) + applyAnalytics(stored.analytics) }, } } diff --git a/tests/analytics/analytics-host.spec.ts b/tests/analytics/analytics-host.spec.ts new file mode 100644 index 00000000..69bf5897 --- /dev/null +++ b/tests/analytics/analytics-host.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { isAnalyticsHost, productionHostname } from '../../layers/base/utils/analyticsHost' + +const PRODUCTION = 'onelitefeather.net' + +describe('analytics host gate', () => { + it('captures on the production host', () => { + expect(isAnalyticsHost('onelitefeather.net', PRODUCTION)).toBe(true) + }) + + it('stays silent on localhost', () => { + expect(isAnalyticsHost('localhost', PRODUCTION)).toBe(false) + }) + + it('stays silent on 127.0.0.1', () => { + expect(isAnalyticsHost('127.0.0.1', PRODUCTION)).toBe(false) + }) + + it('stays silent on a host with a port, such as the production preview on 8787', () => { + expect(isAnalyticsHost('localhost:8787', PRODUCTION)).toBe(false) + }) + + it('stays silent on a workers.dev preview alias', () => { + expect(isAnalyticsHost('launchpad.example.workers.dev', PRODUCTION)).toBe(false) + }) + + it('stays silent when the hostname is empty', () => { + expect(isAnalyticsHost('', PRODUCTION)).toBe(false) + }) + + it('stays silent when no production host is configured, as in dev and preview builds', () => { + expect(isAnalyticsHost('onelitefeather.net', undefined)).toBe(false) + }) + + it('stays silent when the configured production host is empty', () => { + expect(isAnalyticsHost('', '')).toBe(false) + }) + + it('reads the production hostname from the site URL', () => { + expect(productionHostname('https://onelitefeather.net')).toBe('onelitefeather.net') + }) + + it('has no production hostname when no site URL is configured', () => { + expect(productionHostname(undefined)).toBeUndefined() + }) + + it('has no production hostname when the site URL is not a URL', () => { + expect(productionHostname('onelitefeather.net')).toBeUndefined() + }) +}) diff --git a/tests/consent/controller.spec.ts b/tests/consent/controller.spec.ts index 98252c9a..a0a97398 100644 --- a/tests/consent/controller.spec.ts +++ b/tests/consent/controller.spec.ts @@ -6,8 +6,11 @@ const NOW = new Date('2026-10-10T12:00:00.000Z') const NOW_ISO = NOW.toISOString() const STORED_OFF: ConsentState = { analytics: false, decidedAt: '2026-01-01T00:00:00.000Z' } -/** A fresh controller with recording ports; every test builds its own. */ -function fixture(initial: ConsentState | null = null) { +/** + * A fresh controller with recording ports; every test builds its own. The + * production host is the default, so off-production tests say so explicitly. + */ +function fixture(initial: ConsentState | null = null, { analyticsHost = true } = {}) { const persisted: ConsentState[] = [] const analytics: boolean[] = [] const world = { state: initial, bannerOpen: false } @@ -17,6 +20,7 @@ function fixture(initial: ConsentState | null = null) { setBannerOpen: (open) => { world.bannerOpen = open }, persist: (state) => { persisted.push(state) }, applyAnalytics: (allowed) => { analytics.push(allowed) }, + analyticsHost, }) const bannerVisible = () => isBannerVisible(world.state !== null, world.bannerOpen) return { controller, world, persisted, analytics, bannerVisible } @@ -113,3 +117,24 @@ describe('consent controller', () => { expect(bannerVisible()).toBe(false) }) }) + +describe('consent off the production host', () => { + it('never opts PostHog in when all cookies are accepted', () => { + const { controller, analytics } = fixture(null, { analyticsHost: false }) + controller.acceptAll() + expect(analytics).toEqual([false]) + }) + + it('never opts PostHog in from a choice stored by an earlier visit', () => { + const { controller, analytics } = fixture(null, { analyticsHost: false }) + controller.restore({ analytics: true, decidedAt: '2026-01-01T00:00:00.000Z' }) + expect(analytics).toEqual([false]) + }) + + it('still stores the decision there, so the banner closes', () => { + const { controller, persisted, bannerVisible } = fixture(null, { analyticsHost: false }) + controller.acceptAll() + expect(persisted).toEqual([{ analytics: true, decidedAt: NOW_ISO }]) + expect(bannerVisible()).toBe(false) + }) +})