Skip to content

Commit 0632620

Browse files
committed
feat(analytics): enhance PostHog consent management and user identification for GDPR compliance
1 parent 5b1c694 commit 0632620

4 files changed

Lines changed: 71 additions & 80 deletions

File tree

app/composables/useAnalyticsConsent.ts

Lines changed: 34 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,11 @@
11
/**
2-
* Composable for managing PostHog analytics consent (GDPR compliance).
2+
* Composable for managing PostHog analytics consent.
33
*
4-
* By default, PostHog captures events. Users can opt out via this composable.
5-
* The consent state is persisted in a cookie shared across subdomains
6-
* (e.g. reqcore.com and app.reqcore.com) via the NUXT_PUBLIC_COOKIE_DOMAIN
7-
* runtime config.
8-
*
9-
* Usage:
10-
* const { hasConsented, acceptAnalytics, declineAnalytics } = useAnalyticsConsent()
4+
* Cookieless tracking is ALWAYS active — no consent needed.
5+
* Accepting analytics upgrades to full cookie-based tracking
6+
* (UTM, sessions, identity). Declining keeps cookieless mode.
117
*/
128

13-
import { flushPendingEvents, discardPendingEvents } from '~/composables/useTrack'
14-
159
/** Cookie name — shared across reqcore-web and applirank */
1610
export const CONSENT_COOKIE_NAME = 'reqcore-consent'
1711

@@ -24,7 +18,7 @@ export function useAnalyticsConsent() {
2418
// usePostHog() is auto-imported by @posthog/nuxt, but the module is
2519
// conditionally loaded. Replicate the safe accessor so this composable
2620
// works even when PostHog is not configured (CI, self-hosted without key).
27-
const posthog = (useNuxtApp() as Record<string, unknown>).$posthog as ((() => { opt_in_capturing: () => void, opt_out_capturing: () => void, capture: (event: string, properties?: Record<string, unknown>) => void }) | undefined)
21+
const posthog = (useNuxtApp() as Record<string, unknown>).$posthog as (() => import('posthog-js').PostHog) | undefined
2822
const ph = posthog?.()
2923

3024
const cookieDomain = (useRuntimeConfig().public as Record<string, string>).cookieDomain
@@ -54,50 +48,43 @@ export function useAnalyticsConsent() {
5448

5549
function acceptAnalytics() {
5650
consentCookie.value = 'granted'
57-
// Also clean up legacy key if it still exists
5851
if (import.meta.client) localStorage.removeItem(LEGACY_STORAGE_KEY)
59-
ph?.opt_in_capturing()
60-
// Capture the entry-page pageview now that the user has opted in.
61-
// PostHog's init-time $pageview was suppressed by opt_out_capturing_by_default,
62-
// and subsequent pushState events only fire after this call, so we must
63-
// manually send one for the page the user is currently on.
52+
if (!ph) return
53+
// Upgrade from cookieless to full cookie-based persistence
54+
ph.set_config({
55+
persistence: 'localStorage+cookie',
56+
person_profiles: 'identified_only',
57+
cross_subdomain_cookie: true,
58+
})
59+
// Register UTM + attribution now that we have persistence
6460
if (import.meta.client) {
65-
const url = new URL(window.location.href)
66-
url.search = ''
67-
url.hash = ''
68-
ph?.capture('$pageview', { $current_url: url.toString() })
69-
}
70-
// Replay any events that were buffered before consent was granted
71-
// (e.g. signup_submitted, org_created fired during the auth flow).
72-
if (import.meta.client) {
73-
flushPendingEvents()
61+
const params = new URLSearchParams(window.location.search)
62+
const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const
63+
const utmProps: Record<string, string> = {}
64+
for (const key of utmKeys) {
65+
const val = params.get(key)
66+
if (val) utmProps[key] = val
67+
}
68+
if (Object.keys(utmProps).length > 0) {
69+
ph.register(utmProps)
70+
const firstTouch: Record<string, string> = {}
71+
for (const [k, v] of Object.entries(utmProps)) {
72+
firstTouch[`initial_${k}`] = v
73+
}
74+
ph.register_once(firstTouch)
75+
}
76+
ph.register_once({
77+
initial_referrer: document.referrer || '$direct',
78+
initial_landing_page: window.location.pathname,
79+
})
80+
ph.capture('consent_granted')
7481
}
7582
}
7683

7784
function declineAnalytics() {
7885
consentCookie.value = 'denied'
7986
if (import.meta.client) localStorage.removeItem(LEGACY_STORAGE_KEY)
80-
ph?.opt_out_capturing()
81-
// Discard any events buffered before the user declined.
82-
if (import.meta.client) {
83-
discardPendingEvents()
84-
}
85-
}
86-
87-
// Apply stored consent on mount.
88-
// PostHog defaults to opt_out_capturing_by_default: true (GDPR), so we only
89-
// need to explicitly opt-in for users who previously granted consent.
90-
if (import.meta.client) {
91-
if (consentCookie.value === 'granted') {
92-
ph?.opt_in_capturing()
93-
// Replay any events buffered (in sessionStorage) before this page load —
94-
// e.g. signup_completed or org_created tracked during a flow that ended
95-
// with a hard window.location.href navigation.
96-
flushPendingEvents()
97-
}
98-
else {
99-
ph?.opt_out_capturing()
100-
}
87+
// No action needed — cookieless tracking continues without cookies or person profiles
10188
}
10289

10390
return {

app/composables/usePostHogIdentity.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,15 @@ export async function usePostHogIdentity() {
3232
const previousUser = (prev?.[0] as typeof session.value)?.user
3333

3434
if (user?.id && consented) {
35-
// Only the user ID is forwarded — name and createdAt are intentionally
36-
// omitted so PostHog receives the minimal data needed for analytics.
37-
;($posthogIdentifyUser as (userId: string) => void)(user.id)
35+
// Forward person properties for opted-in users so they're identifiable
36+
// in PostHog. GDPR-safe: gated on explicit user consent.
37+
;($posthogIdentifyUser as (userId: string, properties?: Record<string, string | undefined>) => void)(
38+
user.id,
39+
{
40+
email: user.email,
41+
name: user.name || undefined,
42+
},
43+
)
3844
}
3945
else if (previousUser?.id && !user?.id) {
4046
// Always reset on log-out regardless of consent state so that

app/plugins/posthog-identity.client.ts

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,13 @@
22
* Client-only plugin that identifies the current user and organization
33
* in PostHog whenever the auth session is available.
44
*
5-
* - Applies stored consent from the cross-subdomain cookie BEFORE any
6-
* identity events fire, preventing the race condition where identify()
7-
* is discarded because PostHog is still in opt_out_capturing_by_default
8-
* mode when ConsentBanner hasn't mounted yet.
9-
* - Calls posthog.identify() with the user's ID and safe properties
5+
* - Upgrades PostHog from cookieless to full cookie-based persistence
6+
* for returning opted-in users, BEFORE identity watchers fire.
7+
* - Calls posthog.identify() with the user's ID and person properties
108
* - Calls posthog.group() for the active organization (group analytics)
119
* - Resets PostHog on sign-out to avoid cross-user data leakage
1210
*/
1311
import { CONSENT_COOKIE_NAME } from '~/composables/useAnalyticsConsent'
14-
import { flushPendingEvents } from '~/composables/useTrack'
1512

1613
// URL properties that may carry tokens or invitation IDs — always sanitized.
1714
// Includes referrer properties: if a user navigated from /jobs?invite_token=xxx,
@@ -65,23 +62,21 @@ export default defineNuxtPlugin({
6562
urlModified = true
6663
}
6764

68-
// ── Apply stored consent BEFORE any events fire ──
69-
// PostHog starts with opt_out_capturing_by_default: true. If the user has
70-
// already consented, we must call opt_in_capturing() here — before the
71-
// identity watchers in usePostHogIdentity fire — otherwise identify() and
72-
// group() calls are silently discarded while PostHog is still opted-out.
65+
// ── Apply stored consent: upgrade persistence for returning opted-in users ──
66+
// PostHog starts with cookieless tracking (persistence: 'memory',
67+
// person_profiles: 'never'). If the user already consented, upgrade to
68+
// full cookie-based persistence before identity watchers fire so that
69+
// identify() and group() calls create person profiles.
7370
const storedConsent = consentCookie.value
7471
if (storedConsent === 'granted') {
75-
posthog.opt_in_capturing()
76-
// Replay any events buffered in sessionStorage from a previous page load
77-
// (e.g. signup_completed, org_created tracked during flows that ended
78-
// with a hard window.location.href navigation).
79-
flushPendingEvents()
80-
}
81-
else {
82-
// Ensure opt-out state is explicit for new visitors and users who declined.
83-
posthog.opt_out_capturing()
72+
posthog.set_config({
73+
persistence: 'localStorage+cookie',
74+
person_profiles: 'identified_only',
75+
cross_subdomain_cookie: true,
76+
})
8477
}
78+
// No else needed: cookieless tracking continues for non-consented visitors.
79+
// Events still flow (aggregate analytics) but no person profiles are created.
8580

8681
// ── Privacy: strip query params and hashes from captured URLs ──
8782
// These may contain tokens, invitation IDs, or other PII.
@@ -128,11 +123,12 @@ export default defineNuxtPlugin({
128123
// after the app has mounted and auth session is available.
129124
return {
130125
provide: {
131-
// Only the user ID (UUID) is sent — name and createdAt are omitted to
132-
// satisfy GDPR data minimisation (Art. 5(1)(c)): a stable distinct_id
133-
// is sufficient for product analytics without exposing personal data.
134-
posthogIdentifyUser: (userId: string) => {
135-
posthog.identify(userId)
126+
// User ID + person properties (email, name) are sent for opted-in
127+
// users. GDPR-safe: identify() in usePostHogIdentity is gated on
128+
// consent, and person_profiles is 'never' until consent upgrades it
129+
// to 'identified_only'.
130+
posthogIdentifyUser: (userId: string, properties?: Record<string, string | undefined>) => {
131+
posthog.identify(userId, properties)
136132
},
137133
// Only id and human-readable name are forwarded. slug is redundant
138134
// for analytics purposes and is omitted to minimise data collection.

nuxt.config.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,6 @@ export default defineNuxtConfig({
7474
disable_session_recording: true,
7575
enable_recording_console_log: false,
7676
disable_surveys: true,
77-
opt_out_capturing_by_default: true,
78-
respect_dnt: true,
7977
secure_cookie: true,
8078
capture_pageview: true,
8179
capture_pageleave: true,
@@ -85,9 +83,13 @@ export default defineNuxtConfig({
8583
capture_unhandled_rejections: true,
8684
capture_console_errors: false,
8785
},
88-
// ── Persistence ──
89-
persistence: 'localStorage+cookie',
90-
cross_subdomain_cookie: true,
86+
// ── Cookieless by default ──
87+
// No cookies stored until user grants consent. Events still flow for
88+
// aggregate analytics. On consent, persistence is upgraded to
89+
// 'localStorage+cookie' via set_config() in the consent composable.
90+
persistence: 'memory',
91+
person_profiles: 'never',
92+
cross_subdomain_cookie: false,
9193
},
9294
serverConfig: {
9395
// Capture uncaught exceptions and unhandled rejections on the server

0 commit comments

Comments
 (0)