-
Notifications
You must be signed in to change notification settings - Fork 19
feat(analytics): integrate PostHog for user analytics and consent management #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
619f239
f532a3e
ddb1f59
1e948cb
9958fe5
0302102
24a9201
a0d17db
5e708fa
92588d9
c28356a
91c6550
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| /** | ||
| * Composable for managing PostHog analytics consent (GDPR compliance). | ||
| * | ||
| * By default, PostHog captures events. Users can opt out via this composable. | ||
| * The consent state is persisted in localStorage. | ||
| * | ||
| * Usage: | ||
| * const { hasConsented, acceptAnalytics, declineAnalytics } = useAnalyticsConsent() | ||
| */ | ||
| const CONSENT_KEY = 'reqcore-analytics-consent' | ||
|
|
||
| type ConsentState = 'granted' | 'denied' | null | ||
|
|
||
| export function useAnalyticsConsent() { | ||
| // usePostHog() is auto-imported by @posthog/nuxt, but the module is | ||
| // conditionally loaded. Replicate the safe accessor so this composable | ||
| // works even when PostHog is not configured (CI, self-hosted without key). | ||
| const posthog = (useNuxtApp() as Record<string, unknown>).$posthog as ((() => { opt_in_capturing: () => void, opt_out_capturing: () => void }) | undefined) | ||
| const ph = posthog?.() | ||
|
|
||
| const consentState = useState<ConsentState>('analytics-consent', () => { | ||
| if (import.meta.server) return null | ||
| return (localStorage.getItem(CONSENT_KEY) as ConsentState) || null | ||
| }) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| const hasConsented = computed(() => consentState.value === 'granted') | ||
| const hasDeclined = computed(() => consentState.value === 'denied') | ||
| const needsConsent = computed(() => consentState.value === null) | ||
|
|
||
| function acceptAnalytics() { | ||
| consentState.value = 'granted' | ||
| if (import.meta.client) { | ||
| localStorage.setItem(CONSENT_KEY, 'granted') | ||
| } | ||
| ph?.opt_in_capturing() | ||
| } | ||
|
|
||
| function declineAnalytics() { | ||
| consentState.value = 'denied' | ||
| if (import.meta.client) { | ||
| localStorage.setItem(CONSENT_KEY, 'denied') | ||
| } | ||
| ph?.opt_out_capturing() | ||
| } | ||
|
|
||
| // Apply stored consent on mount | ||
| if (import.meta.client && consentState.value === 'denied') { | ||
| ph?.opt_out_capturing() | ||
| } | ||
|
|
||
| return { | ||
| hasConsented, | ||
| hasDeclined, | ||
| needsConsent, | ||
| acceptAnalytics, | ||
| declineAnalytics, | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| /** | ||
| * Composable that syncs the Better Auth session with PostHog identity. | ||
| * Call once in a root-level layout or app.vue to enable automatic | ||
| * user identification and organization group analytics. | ||
| * | ||
| * Must be called in `<script setup>` context (not in a plugin). | ||
| */ | ||
| export async function usePostHogIdentity() { | ||
| const { $posthogIdentifyUser, $posthogSetOrganization, $posthogReset } = useNuxtApp() | ||
|
|
||
| if (!$posthogIdentifyUser) return | ||
|
|
||
| const { data: session } = await authClient.useSession(useFetch) | ||
| const activeOrgState = authClient.useActiveOrganization() | ||
|
|
||
| // Watch session changes to identify/reset user | ||
| watch( | ||
| () => session.value, | ||
| (currentSession, previousSession) => { | ||
| const user = currentSession?.user | ||
|
|
||
| if (user?.id) { | ||
| ($posthogIdentifyUser as (user: { id: string, name?: string, createdAt?: string }) => void)({ | ||
| id: user.id, | ||
| name: user.name || undefined, | ||
| createdAt: user.createdAt ? String(user.createdAt) : undefined, | ||
| }) | ||
| } | ||
| else if (previousSession?.user?.id) { | ||
| ($posthogReset as () => void)() | ||
| } | ||
| }, | ||
| { immediate: true }, | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // Watch active organization for group analytics | ||
| watch( | ||
| () => activeOrgState.value?.data, | ||
| (org) => { | ||
| if (org?.id) { | ||
| ($posthogSetOrganization as (org: { id: string, name?: string, slug?: string }) => void)({ | ||
| id: org.id, | ||
| name: org.name || undefined, | ||
| slug: org.slug || undefined, | ||
| }) | ||
| } | ||
| }, | ||
| { immediate: true }, | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| /** | ||
| * Client-only plugin that identifies the current user and organization | ||
| * in PostHog whenever the auth session is available. | ||
| * | ||
| * - Calls posthog.identify() with the user's ID and safe properties | ||
| * - Calls posthog.group() for the active organization (group analytics) | ||
| * - Resets PostHog on sign-out to avoid cross-user data leakage | ||
| */ | ||
| export default defineNuxtPlugin({ | ||
| name: 'posthog-identity', | ||
| parallel: true, | ||
| setup() { | ||
| // usePostHog() is auto-imported by @posthog/nuxt, but the module is | ||
| // conditionally loaded (only when POSTHOG_PUBLIC_KEY is set). In CI and | ||
| // local-dev without the key the auto-import doesn't exist, so we replicate | ||
| // its safe accessor here: $posthog is a function provided by the module's | ||
| // plugin — when the module isn't loaded it simply won't be on NuxtApp. | ||
| const $ph = (useNuxtApp() as Record<string, unknown>).$posthog as (() => import('posthog-js').PostHog) | undefined | ||
| const posthog = $ph?.() | ||
| if (!posthog) return | ||
|
|
||
| // ── Privacy: strip query params and hashes from captured URLs ── | ||
| // These may contain tokens, invitation IDs, or other PII. | ||
| const originalCapture = posthog.capture.bind(posthog) | ||
| posthog.capture = (eventName: string, properties?: Record<string, unknown>, options?: unknown) => { | ||
| const props = { ...properties } | ||
| if (typeof props['$current_url'] === 'string') { | ||
| try { | ||
| const url = new URL(props['$current_url']) | ||
| url.search = '' | ||
| url.hash = '' | ||
| props['$current_url'] = url.toString() | ||
| } | ||
| catch { /* keep original if parsing fails */ } | ||
| } | ||
| return originalCapture(eventName, props, options as never) | ||
| } | ||
|
|
||
| // Provide a hook that components/composables can use to identify the user | ||
| // after the app has mounted and auth session is available. | ||
| return { | ||
| provide: { | ||
| posthogIdentifyUser: (user: { id: string, name?: string, createdAt?: string }) => { | ||
| posthog.identify(user.id, { | ||
| name: user.name || undefined, | ||
| createdAt: user.createdAt || undefined, | ||
| }) | ||
| }, | ||
| posthogSetOrganization: (org: { id: string, name?: string, slug?: string }) => { | ||
| posthog.group('organization', org.id, { | ||
| name: org.name || undefined, | ||
| slug: org.slug || undefined, | ||
| }) | ||
| }, | ||
| posthogReset: () => { | ||
| posthog.reset() | ||
| }, | ||
| }, | ||
| } | ||
| }, | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,43 @@ | ||
| import { defineConfig } from 'drizzle-kit' | ||
|
|
||
| /** | ||
| * Resolve a valid database URL, with a fallback for Railway PR/preview environments | ||
| * where DATABASE_URL may have an empty hostname due to unresolved variable references. | ||
| * Falls back to individual PG* and RAILWAY_TCP_PROXY_* variables when available. | ||
| */ | ||
| function resolveDatabaseUrl(): string { | ||
| const raw = process.env.DATABASE_URL ?? '' | ||
|
|
||
| try { | ||
| const parsed = new URL(raw) | ||
| if (parsed.hostname) return raw | ||
| } | ||
| catch { | ||
| // fall through to individual-variable reconstruction | ||
| } | ||
|
|
||
| const host = process.env.PGHOST ?? process.env.RAILWAY_TCP_PROXY_DOMAIN ?? '' | ||
| const port = process.env.PGPORT ?? process.env.RAILWAY_TCP_PROXY_PORT ?? '5432' | ||
| const user = process.env.PGUSER ?? 'postgres' | ||
| const password = process.env.PGPASSWORD ?? '' | ||
| const database = process.env.PGDATABASE ?? 'railway' | ||
|
|
||
| if (host) { | ||
| return `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${database}` | ||
| } | ||
|
|
||
| throw new Error( | ||
| `DATABASE_URL is missing a hostname and no PGHOST fallback is available.\n` | ||
| + `Raw DATABASE_URL: "${raw}"\n` | ||
| + `In Railway PR environments, ensure the Postgres service variables are linked to this service.`, | ||
| ) | ||
| } | ||
|
Comment on lines
+8
to
+34
|
||
|
|
||
| export default defineConfig({ | ||
| schema: './server/database/schema/index.ts', | ||
| out: './server/database/migrations', | ||
| dialect: 'postgresql', | ||
| dbCredentials: { | ||
| url: process.env.DATABASE_URL!, | ||
| url: resolveDatabaseUrl(), | ||
|
Comment on lines
+3
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Apply the same DB URL fallback in the runtime path.
🤖 Prompt for AI Agents |
||
| }, | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The JSDoc comment says "By default, PostHog captures events. Users can opt out via this composable." but this is incorrect given the configuration in
nuxt.config.tswhich setsopt_out_capturing_by_default: true. In reality, PostHog starts in opted-out mode and users must opt in for any capturing to occur. The docstring should read "By default, PostHog does not capture events. Users can opt in via this composable."