Skip to content

Commit 0d51cd5

Browse files
authored
Merge pull request #160 from reqcore-inc/fix/posthog
feat: Implement two-tier consent model for PostHog analytics
2 parents fc2708f + ef7fee5 commit 0d51cd5

9 files changed

Lines changed: 547 additions & 283 deletions

File tree

app/composables/useAnalyticsConsent.ts

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
/**
22
* Composable for managing PostHog analytics consent.
33
*
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.
4+
* Two-tier model
5+
* --------------
6+
* - **No choice yet OR declined**: PostHog runs in cookieless mode
7+
* (`persistence: 'memory'`, `person_profiles: 'identified_only'`).
8+
* Logged-in users are still identified by their opaque user.id (so we
9+
* can count returning users and per-user metrics) but no email/name is
10+
* forwarded and nothing is stored on the visitor's device.
11+
*
12+
* - **Accepted**: PostHog persistence is upgraded to `localStorage+cookie`
13+
* so the distinct id survives reloads, then `identify(userId, { email,
14+
* name })` is re-fired with full PII. PostHog automatically aliases the
15+
* current anonymous distinct id → user id, stitching the pre-signup
16+
* funnel into the user's profile.
717
*/
818

919
/** Cookie name — shared across reqcore-web and applirank */
1020
export const CONSENT_COOKIE_NAME = 'reqcore-consent'
1121

12-
/** @deprecated Old localStorage key — used only for one-time migration */
13-
const LEGACY_STORAGE_KEY = 'reqcore-analytics-consent'
14-
1522
type ConsentState = 'granted' | 'denied' | null
1623

1724
export function useAnalyticsConsent() {
@@ -32,32 +39,26 @@ export function useAnalyticsConsent() {
3239
sameSite: 'lax',
3340
})
3441

35-
// One-time migration: move consent from localStorage to cookie for users
36-
// who consented before this change, then clean up localStorage.
37-
if (import.meta.client && !consentCookie.value) {
38-
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY)
39-
if (legacy === 'granted' || legacy === 'denied') {
40-
consentCookie.value = legacy
41-
localStorage.removeItem(LEGACY_STORAGE_KEY)
42-
}
43-
}
44-
4542
const hasConsented = computed(() => consentCookie.value === 'granted')
4643
const hasDeclined = computed(() => consentCookie.value === 'denied')
4744
const needsConsent = computed(() => !consentCookie.value)
4845

4946
function acceptAnalytics() {
5047
consentCookie.value = 'granted'
51-
if (import.meta.client) localStorage.removeItem(LEGACY_STORAGE_KEY)
5248
if (!ph) return
53-
// Upgrade from cookieless to full cookie-based persistence
49+
50+
// Upgrade from cookieless to cookie+localStorage persistence so the
51+
// distinct id survives reloads and new tabs. After this, the watcher
52+
// in `usePostHogIdentity` (which depends on `hasConsented`) re-fires
53+
// and re-identifies the user with full PII — that identify() call
54+
// automatically aliases the current anonymous distinct id → user id.
5455
ph.set_config({
5556
persistence: 'localStorage+cookie',
56-
person_profiles: 'identified_only',
5757
cross_subdomain_cookie: true,
5858
})
59-
// Register UTM + attribution now that we have persistence
59+
6060
if (import.meta.client) {
61+
// Capture UTM + first-touch attribution now that we have persistence
6162
const params = new URLSearchParams(window.location.search)
6263
const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const
6364
const utmProps: Record<string, string> = {}
@@ -83,8 +84,8 @@ export function useAnalyticsConsent() {
8384

8485
function declineAnalytics() {
8586
consentCookie.value = 'denied'
86-
if (import.meta.client) localStorage.removeItem(LEGACY_STORAGE_KEY)
87-
// No action needed — cookieless tracking continues without cookies or person profiles
87+
// No PostHog action needed — cookieless mode (memory + identified_only)
88+
// continues, no person profile properties are sent, no cookies are set.
8889
}
8990

9091
return {

app/composables/usePostHogIdentity.ts

Lines changed: 59 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,79 +5,99 @@
55
*
66
* Must be called in `<script setup>` context (not in a plugin).
77
*
8-
* Identity and group calls are gated on analytics consent so that events are
9-
* never sent before the user has opted in. When a user grants consent during
10-
* their session (ConsentBanner), the watchers re-fire and identify them
11-
* immediately without requiring a page reload.
8+
* Identity model
9+
* --------------
10+
* Logged-in users are ALWAYS identified by their opaque `user.id` so that
11+
* funnel + retention analytics work even for visitors who never accept
12+
* cookies. Email and name are forwarded ONLY when the user has explicitly
13+
* consented to analytics — keeping the dashboard PII-free for non-consenters
14+
* while still letting consented users be looked up by email in PostHog.
15+
*
16+
* The watcher depends on `hasConsented`, so when a user clicks "Accept" in
17+
* the banner the identify() call re-fires immediately and PostHog aliases
18+
* the anonymous distinct id to the user id (preserving the in-session
19+
* funnel from anonymous visit → signup → identified user).
1220
*/
1321
export async function usePostHogIdentity() {
14-
const { $posthogIdentifyUser, $posthogSetOrganization, $posthogReset, $posthogResetGroups } = useNuxtApp()
22+
const { $posthogIdentifyUser, $posthogSetOrganization, $posthogReset, $posthogResetGroups, $posthogSetDemoFlag } = useNuxtApp()
1523

1624
if (!$posthogIdentifyUser) return
1725

1826
const { data: session } = await authClient.useSession(useFetch)
1927
const activeOrgState = authClient.useActiveOrganization()
2028

21-
// Share the consent reactive state. useAnalyticsConsent also applies the
22-
// stored consent flag to PostHog on the client, so calling it here means
23-
// consent is active before the immediate watchers fire below.
2429
const { hasConsented } = useAnalyticsConsent()
2530

26-
// Watch session AND consent so identify re-fires when a new user accepts
27-
// consent during their visit, and is skipped when PostHog is opted-out.
31+
const config = useRuntimeConfig()
32+
const liveDemoEmail = (config.public.liveDemoEmail as string | undefined) || ''
33+
const demoOrgSlug = (config.public.demoOrgSlug as string | undefined) || ''
34+
2835
watch(
2936
[() => session.value, hasConsented] as const,
3037
([currentSession, consented], prev) => {
3138
const user = currentSession?.user
3239
const previousUser = (prev?.[0] as typeof session.value)?.user
3340

34-
if (user?.id && consented) {
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-
{
41+
if (user?.id) {
42+
// Forward person properties (email, name) ONLY if consent was given.
43+
// Without consent we still identify with the opaque user.id so
44+
// returning users are stable in PostHog — but no PII is attached.
45+
const identify = $posthogIdentifyUser as (
46+
userId: string,
47+
properties?: Record<string, string | undefined>,
48+
) => void
49+
if (consented) {
50+
identify(user.id, {
4051
email: user.email,
4152
name: user.name || undefined,
42-
},
43-
)
53+
})
54+
}
55+
else {
56+
identify(user.id)
57+
}
58+
59+
// Demo tagging by user email — fires the moment the demo account
60+
// signs in, even before an organisation context is loaded.
61+
const setDemoFlag = $posthogSetDemoFlag as ((isDemo: boolean) => void) | undefined
62+
if (setDemoFlag && liveDemoEmail) {
63+
setDemoFlag(user.email === liveDemoEmail)
64+
}
4465
}
4566
else if (previousUser?.id && !user?.id) {
4667
// Always reset on log-out regardless of consent state so that
4768
// no user identity leaks into the next anonymous session.
69+
// Reset also clears the registered super properties (incl. is_demo).
4870
($posthogReset as () => void)()
4971
}
5072
},
5173
{ immediate: true },
5274
)
5375

54-
// Watch org AND consent for group analytics — same gating logic as above.
5576
watch(
5677
[() => activeOrgState.value?.data, hasConsented] as const,
5778
async ([org, consented]) => {
58-
if (consented) {
59-
if (org?.id) {
60-
// Fetch the current member's role to enrich group properties — useful
61-
// for debugging permission issues without exposing personal data.
62-
let memberRole: string | undefined
63-
try {
64-
const { data } = await authClient.organization.getActiveMemberRole()
65-
memberRole = data?.role ?? undefined
66-
}
67-
catch { /* non-critical; role is just an enrichment property */ }
68-
69-
// Only org id, name, and member role are forwarded; slug is omitted to minimise data.
70-
;($posthogSetOrganization as (org: { id: string, name?: string, member_role?: string }) => void)({
71-
id: org.id,
72-
name: org.name || undefined,
73-
member_role: memberRole,
74-
})
79+
if (org?.id) {
80+
// Forward org name only for consenters; anonymous users get an
81+
// opaque org-id-only group so per-org metrics still aggregate.
82+
const setOrg = $posthogSetOrganization as (org: { id: string, name?: string }) => void
83+
if (consented) {
84+
setOrg({ id: org.id, name: org.name || undefined })
7585
}
7686
else {
77-
// Clear org group when user has no active organization to avoid
78-
// attributing events to the previously selected org.
79-
($posthogResetGroups as (() => void) | undefined)?.()
87+
setOrg({ id: org.id })
8088
}
89+
90+
// Demo tagging by org slug — covers self-hosted deployments
91+
// where the demo org may be owned by a non-demo email account.
92+
// We only set true here; the email-based watcher above handles
93+
// clearing the flag when the user is not the demo user.
94+
const setDemoFlag = $posthogSetDemoFlag as ((isDemo: boolean) => void) | undefined
95+
if (setDemoFlag && demoOrgSlug && org.slug === demoOrgSlug) {
96+
setDemoFlag(true)
97+
}
98+
}
99+
else {
100+
($posthogResetGroups as (() => void) | undefined)?.()
81101
}
82102
},
83103
{ immediate: true },

app/composables/useTrack.ts

Lines changed: 12 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,114 +1,41 @@
11
/**
2-
* Composable for privacy-respecting PostHog funnel tracking.
2+
* Composable for privacy-respecting PostHog event tracking.
33
*
4-
* Wraps posthog.capture() with:
5-
* - Consent gating (no-ops when user hasn't opted in)
6-
* - Pre-consent event buffering (replayed when user consents)
7-
* - Auto-enrichment with page context (route path, viewport width)
4+
* Wraps posthog.capture() with auto-enrichment (route path, viewport width).
5+
*
6+
* Events are ALWAYS captured — even before the consent banner is answered —
7+
* because PostHog runs in `persistence: 'memory'` mode by default, so the
8+
* anonymous distinct id never reaches the visitor's storage (no cookies, no
9+
* localStorage). When the user later accepts cookies, persistence is
10+
* upgraded and `identify(userId, …)` automatically aliases the in-memory
11+
* anonymous id to the user id, so the in-session funnel is preserved.
812
*/
913
import type { PostHog } from 'posthog-js'
1014

11-
interface PendingEvent {
12-
eventName: string
13-
properties: Record<string, unknown>
14-
}
15-
16-
// Module-level buffer for events captured before consent is granted.
17-
// Flushed to PostHog when the user accepts analytics; discarded on decline.
18-
// Persisted to sessionStorage so the buffer survives hard page reloads
19-
// (e.g. window.location.href navigation in createOrg/switchOrg).
20-
const MAX_PENDING_EVENTS = 50
21-
const STORAGE_KEY = 'ph-pending-events'
22-
23-
function restoreBuffer(): PendingEvent[] {
24-
if (!import.meta.client) return []
25-
try {
26-
const stored = sessionStorage.getItem(STORAGE_KEY)
27-
return stored ? JSON.parse(stored) as PendingEvent[] : []
28-
}
29-
catch { return [] }
30-
}
31-
32-
function persistBuffer() {
33-
if (!import.meta.client) return
34-
try {
35-
if (pendingEvents.length > 0) {
36-
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(pendingEvents))
37-
}
38-
else {
39-
sessionStorage.removeItem(STORAGE_KEY)
40-
}
41-
}
42-
catch { /* storage full or unavailable */ }
43-
}
44-
45-
const pendingEvents: PendingEvent[] = restoreBuffer()
46-
4715
function getPostHog(): PostHog | undefined {
4816
const $ph = (useNuxtApp() as Record<string, unknown>).$posthog as (() => PostHog) | undefined
4917
return $ph?.()
5018
}
5119

52-
/**
53-
* Flush buffered pre-consent events to PostHog.
54-
* Called by useAnalyticsConsent.acceptAnalytics() after opting in.
55-
*/
56-
export function flushPendingEvents() {
57-
const ph = getPostHog()
58-
if (!ph || !ph.has_opted_in_capturing()) return
59-
while (pendingEvents.length > 0) {
60-
const event = pendingEvents.shift()!
61-
ph.capture(event.eventName, event.properties)
62-
}
63-
persistBuffer()
64-
}
65-
66-
/**
67-
* Discard buffered pre-consent events (user declined analytics).
68-
*/
69-
export function discardPendingEvents() {
70-
pendingEvents.length = 0
71-
persistBuffer()
72-
}
73-
7420
export function useTrack() {
7521
const route = useRoute()
7622

77-
/**
78-
* Send a custom event to PostHog (consent-gated).
79-
* Automatically includes current route path and viewport width.
80-
*
81-
* If PostHog is not yet opted-in (consent pending), the event is
82-
* buffered and replayed when/if the user grants consent.
83-
*/
8423
function track(eventName: string, properties?: Record<string, unknown>) {
8524
if (!import.meta.client) return
8625
const ph = getPostHog()
8726
if (!ph) return
8827

89-
const enrichedProps = {
28+
ph.capture(eventName, {
9029
path: route.path,
9130
viewport_width: window.innerWidth,
9231
...properties,
93-
}
94-
95-
if (ph.has_opted_in_capturing()) {
96-
ph.capture(eventName, enrichedProps)
97-
}
98-
else if (pendingEvents.length < MAX_PENDING_EVENTS) {
99-
pendingEvents.push({ eventName, properties: enrichedProps })
100-
persistBuffer()
101-
}
32+
})
10233
}
10334

104-
/**
105-
* Report a caught error to PostHog's error tracking (consent-gated).
106-
* Use for errors that are handled in catch blocks but still worth logging.
107-
*/
10835
function captureError(error: unknown, properties?: Record<string, unknown>) {
10936
if (!import.meta.client) return
11037
const ph = getPostHog()
111-
if (!ph || !ph.has_opted_in_capturing()) return
38+
if (!ph) return
11239

11340
ph.captureException(error instanceof Error ? error : new Error(String(error)), {
11441
path: route.path,

0 commit comments

Comments
 (0)