feat(analytics): integrate PostHog for user analytics and consent management#74
feat(analytics): integrate PostHog for user analytics and consent management#74
Conversation
…agement - Added PostHog Nuxt module and posthog-node for server-side event capture. - Implemented useAnalyticsConsent composable for managing user consent (GDPR compliance). - Created usePostHogIdentity composable to sync Better Auth session with PostHog identity. - Developed posthog-identity client plugin to identify users and organizations in PostHog. - Introduced server-side PostHog client for backend event capture.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds PostHog analytics integration: conditional module/config and deps, client plugin and composables for identity and consent, server PostHog client utility, DB URL resolver helpers, new ConsentBanner and PublicNavBar components, and a top-level await in root app to sync identity before rendering. Changes
Sequence DiagramsequenceDiagram
autonumber
actor User
participant Browser as "Browser / Client"
participant App as "Nuxt App"
participant Auth as "Auth Session"
participant Consent as "useAnalyticsConsent"
participant Identity as "usePostHogIdentity"
participant Plugin as "posthog-identity plugin"
participant PostHog as "PostHog Service"
participant Server as "Server (useServerPostHog)"
User->>Browser: navigate to app
Browser->>App: mount
App->>Identity: top-level await usePostHogIdentity()
Identity->>Auth: subscribe to session & org (immediate)
Auth-->>Identity: session with user
Identity->>Plugin: posthogIdentifyUser(user)
Plugin->>PostHog: identify(user.id, {name, createdAt})
Identity->>Plugin: posthogSetOrganization(org)
Plugin->>PostHog: group('organization', org.id, {name, slug})
App->>Consent: check consent (localStorage)
Consent-->>Plugin: opt_in/opt_out (if PostHog present)
Browser->>Plugin: capture(event) [query/hash stripped]
Plugin->>PostHog: capture(event)
App->>Server: request causing server event
Server->>Server: useServerPostHog() -> cached client
Server->>PostHog: capture(server event)
Auth-->>Identity: user signed out
Identity->>Plugin: posthogReset()
Plugin->>PostHog: reset()
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
server/utils/posthog.ts (1)
12-31: Consider flushing events on server shutdown.The singleton PostHog client buffers events (up to 20 events or 10 seconds). If the server shuts down gracefully, buffered events may be lost. PostHog's Node SDK provides a
shutdown()method to flush pending events.♻️ Proposed enhancement to handle graceful shutdown
export function useServerPostHog(): PostHog | null { if (client) return client const config = useRuntimeConfig() const publicKey = config.public.posthog?.publicKey const host = config.public.posthog?.host if (!publicKey) { return null } client = new PostHog(publicKey, { host: host || 'https://eu.i.posthog.com', // Flush events every 10 seconds or 20 events, whichever comes first flushAt: 20, flushInterval: 10_000, }) + // Flush pending events on graceful shutdown + process.on('SIGTERM', async () => { + await client?.shutdown() + }) + return client }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/utils/posthog.ts` around lines 12 - 31, The PostHog singleton created in useServerPostHog currently buffers events and never flushes on shutdown; update useServerPostHog to register a graceful shutdown handler that calls client.shutdown() (or awaits it) when the process/server is stopping so buffered events are flushed; reference the existing client and PostHog instance returned by useServerPostHog and ensure the shutdown handler is idempotent (only registers once) and handles errors from client.shutdown() appropriately.app/plugins/posthog-identity.client.ts (1)
16-31: Consider sanitizing$referreras well.The URL sanitization correctly strips query params and hashes from
$current_url, but$referrercan also contain sensitive data (e.g., if the user came from a page with tokens in the URL).♻️ Proposed enhancement to also sanitize $referrer
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 */ } - } + for (const key of ['$current_url', '$referrer']) { + if (typeof props[key] === 'string') { + try { + const url = new URL(props[key]) + url.search = '' + url.hash = '' + props[key] = url.toString() + } + catch { /* keep original if parsing fails */ } + } + } return originalCapture(eventName, props, options as never) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/plugins/posthog-identity.client.ts` around lines 16 - 31, The sanitization currently strips query and hash from props['$current_url'] but not props['$referrer']; update posthog.capture to apply the same URL-parsing logic to props['$referrer'] as well (use the same try/catch block used for props['$current_url']), clearing search and hash on successful parse, leaving the original value if parsing fails, and then call originalCapture(eventName, props, options) as before.app/app.vue (1)
11-13: Top-level await may delay initial render.The
await usePostHogIdentity()blocks rendering until the auth session is fetched. This is intentional to ensure identity sync before analytics events, but could impact perceived load time for users.Consider whether the identity sync could be deferred to after initial render (e.g., in an
onMountedhook or via a non-blocking pattern) if load performance becomes a concern in testing.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/app.vue` around lines 11 - 13, The top-level await on usePostHogIdentity is blocking initial render; change it to run post-mount or non-blocking: move the call into the component lifecycle (e.g., call usePostHogIdentity() from onMounted and await there) or invoke it without awaiting (fire-and-forget) so rendering isn't delayed; locate the current top-level await usePostHogIdentity() in app.vue and replace it with an onMounted(() => { await usePostHogIdentity(); }) or onMounted(() => { usePostHogIdentity(); }) depending on whether you need to wait for completion before other post-mount work.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/composables/useAnalyticsConsent.ts`:
- Around line 42-45: Change default PostHog behavior to opt-out-by-default and
ensure the composable enforces opt-out until explicit accept; set
opt_out_capturing_by_default: true in nuxt.config.ts and update the
useAnalyticsConsent logic so that on mount it always calls
posthog?.opt_out_capturing() unless consentState === 'granted', and only calls
posthog?.opt_in_capturing() (or equivalent) from acceptAnalytics(); reference
useAnalyticsConsent, consentState, acceptAnalytics, and the
posthog?.opt_out_capturing()/opt_in_capturing() calls when making these changes.
- Around line 14-54: The composable useAnalyticsConsent is never used in the app
so users cannot grant/deny analytics; implement a small ConsentBanner component
that imports useAnalyticsConsent and displays when needsConsent is true, calling
acceptAnalytics() and declineAnalytics() on the respective buttons and using
hasConsented/hasDeclined for state, then mount that component globally by adding
it to the root layout (e.g., default layout or App.vue) so it renders for all
pages; ensure the component only accesses localStorage/client-only behavior on
import.meta.client or inside onMounted to match the composable’s SSR checks and
ensure posthog methods (posthog?.opt_in_capturing/opt_out_capturing via the
composable) are invoked when buttons are clicked.
In `@app/composables/usePostHogIdentity.ts`:
- Around line 36-49: The watcher on activeOrgState currently only calls
$posthogSetOrganization when org?.id is truthy, leaving previous org groups set
when org becomes null; modify the callback in the watch (the function
referencing activeOrgState, org and $posthogSetOrganization) to call
resetGroups() (PostHog's resetGroups) when org is falsy/undefined so all
organization group associations are cleared, otherwise continue to call
$posthogSetOrganization with the id/name/slug as before; ensure you import or
reference resetGroups in the same scope as $posthogSetOrganization and keep the
immediate: true behavior.
---
Nitpick comments:
In `@app/app.vue`:
- Around line 11-13: The top-level await on usePostHogIdentity is blocking
initial render; change it to run post-mount or non-blocking: move the call into
the component lifecycle (e.g., call usePostHogIdentity() from onMounted and
await there) or invoke it without awaiting (fire-and-forget) so rendering isn't
delayed; locate the current top-level await usePostHogIdentity() in app.vue and
replace it with an onMounted(() => { await usePostHogIdentity(); }) or
onMounted(() => { usePostHogIdentity(); }) depending on whether you need to wait
for completion before other post-mount work.
In `@app/plugins/posthog-identity.client.ts`:
- Around line 16-31: The sanitization currently strips query and hash from
props['$current_url'] but not props['$referrer']; update posthog.capture to
apply the same URL-parsing logic to props['$referrer'] as well (use the same
try/catch block used for props['$current_url']), clearing search and hash on
successful parse, leaving the original value if parsing fails, and then call
originalCapture(eventName, props, options) as before.
In `@server/utils/posthog.ts`:
- Around line 12-31: The PostHog singleton created in useServerPostHog currently
buffers events and never flushes on shutdown; update useServerPostHog to
register a graceful shutdown handler that calls client.shutdown() (or awaits it)
when the process/server is stopping so buffered events are flushed; reference
the existing client and PostHog instance returned by useServerPostHog and ensure
the shutdown handler is idempotent (only registers once) and handles errors from
client.shutdown() appropriately.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 74673f5f-27fd-4189-b3fa-b736872c54ca
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (7)
app/app.vueapp/composables/useAnalyticsConsent.tsapp/composables/usePostHogIdentity.tsapp/plugins/posthog-identity.client.tsnuxt.config.tspackage.jsonserver/utils/posthog.ts
…ved functionality
…bility to prevent crashes
… plugins to ensure compatibility when not configured
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
app/composables/useAnalyticsConsent.ts (1)
46-49: Consider symmetrically applyingopt_in_capturingfor users who previously granted consent.Currently, only
opt_out_capturing()is called for'denied'state on initialization. If a user previously granted consent but PostHog's internal state becomes out of sync (e.g., cookies cleared, library reset), theopt_in_capturing()call is never re-applied.This is likely fine in practice since PostHog defaults to capturing enabled, but symmetric handling would be more defensive.
♻️ Optional: Apply consent symmetrically
// Apply stored consent on mount - if (import.meta.client && consentState.value === 'denied') { - ph?.opt_out_capturing() + if (import.meta.client) { + if (consentState.value === 'denied') { + ph?.opt_out_capturing() + } else if (consentState.value === 'granted') { + ph?.opt_in_capturing() + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/composables/useAnalyticsConsent.ts` around lines 46 - 49, The initialization only calls ph?.opt_out_capturing() when consentState.value === 'denied', so add a symmetric call to ph?.opt_in_capturing() when consentState.value indicates consent was granted (e.g., 'granted' or the accepted enum/string your app uses); update the block in useAnalyticsConsent.ts that currently checks import.meta.client and consentState.value === 'denied' to also check for the granted state and call ph?.opt_in_capturing() accordingly, ensuring you use the same consentState variable and the ph optional chain already present.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/composables/useAnalyticsConsent.ts`:
- Around line 21-24: The code unsafely casts localStorage value to ConsentState
in the useState initializer (consentState) which can produce invalid/ambiguous
states; instead, read the raw string from localStorage via CONSENT_KEY, validate
it against the allowed ConsentState values (e.g., "accepted" and "declined" or
whatever the ConsentState union contains) and only return a typed ConsentState
when it matches, otherwise return null (or the explicit default) so the app
shows the consent prompt; update the initializer in useAnalyticsConsent.ts to
perform this validation before assigning to consentState.
---
Nitpick comments:
In `@app/composables/useAnalyticsConsent.ts`:
- Around line 46-49: The initialization only calls ph?.opt_out_capturing() when
consentState.value === 'denied', so add a symmetric call to
ph?.opt_in_capturing() when consentState.value indicates consent was granted
(e.g., 'granted' or the accepted enum/string your app uses); update the block in
useAnalyticsConsent.ts that currently checks import.meta.client and
consentState.value === 'denied' to also check for the granted state and call
ph?.opt_in_capturing() accordingly, ensuring you use the same consentState
variable and the ph optional chain already present.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8162a2dd-ed24-488b-9d3e-d35889f9d11a
📒 Files selected for processing (3)
app/composables/useAnalyticsConsent.tsapp/plugins/posthog-identity.client.tsnuxt.config.ts
✅ Files skipped from review due to trivial changes (1)
- nuxt.config.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- app/plugins/posthog-identity.client.ts
|
🚅 Deployed to the reqcore-pr-74 environment in applirank
|
…g for environment variables
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
server/scripts/seed.ts (1)
42-67: ExtractresolveDatabaseUrl()into a shared utility.This is now a second copy of the resolver from
drizzle.config.ts:8-34, and the two paths already diverge on failure handling (return ''here vs a detailedthrowthere). That makes future env fixes easy to apply in one path and miss in the other.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@server/scripts/seed.ts` around lines 42 - 67, The resolveDatabaseUrl() implementation is duplicated; extract it into a shared utility (e.g., export a resolveDatabaseUrl function from a common module used by both drizzle.config.ts and server/scripts/seed.ts), update seed.ts to import and call that shared resolveDatabaseUrl instead of its local version, and remove the local copy; ensure the shared function's failure behavior is the canonical one (make its error handling/throwing consistent with the version in drizzle.config.ts) so both DATABASE_URL resolution paths use the same logic and error reporting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@drizzle.config.ts`:
- Around line 3-41: drizzle.config.ts implements resolveDatabaseUrl() to fall
back to individual PG*/RAILWAY_TCP_PROXY_* vars; replicate that logic in runtime
by extracting or reusing resolveDatabaseUrl for application startup so env
validation and DB connections use the same fallback. Update server/utils/env.ts
to validate/process the DB URL via the same resolveDatabaseUrl (or a new shared
getDatabaseUrl helper) instead of only checking process.env.DATABASE_URL, and
update server/utils/db.ts to call that helper when constructing the DB client
(replace direct use of env.DATABASE_URL). Ensure the helper preserves
encoding/port/user/password behavior and throws the same descriptive error when
no host can be resolved so migrations and runtime behave identically.
---
Nitpick comments:
In `@server/scripts/seed.ts`:
- Around line 42-67: The resolveDatabaseUrl() implementation is duplicated;
extract it into a shared utility (e.g., export a resolveDatabaseUrl function
from a common module used by both drizzle.config.ts and server/scripts/seed.ts),
update seed.ts to import and call that shared resolveDatabaseUrl instead of its
local version, and remove the local copy; ensure the shared function's failure
behavior is the canonical one (make its error handling/throwing consistent with
the version in drizzle.config.ts) so both DATABASE_URL resolution paths use the
same logic and error reporting.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8c0eac53-05be-48f0-b3a0-189eedeac08b
📒 Files selected for processing (3)
drizzle.config.tsnuxt.config.tsserver/scripts/seed.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- nuxt.config.ts
| /** | ||
| * 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.`, | ||
| ) | ||
| } | ||
|
|
||
| export default defineConfig({ | ||
| schema: './server/database/schema/index.ts', | ||
| out: './server/database/migrations', | ||
| dialect: 'postgresql', | ||
| dbCredentials: { | ||
| url: process.env.DATABASE_URL!, | ||
| url: resolveDatabaseUrl(), |
There was a problem hiding this comment.
Apply the same DB URL fallback in the runtime path.
drizzle.config.ts now handles Railway-style split PG vars, but server/utils/env.ts still validates only a raw DATABASE_URL, and server/utils/db.ts still connects with env.DATABASE_URL directly. In preview environments that means migrations can pass while the app still fails to boot.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@drizzle.config.ts` around lines 3 - 41, drizzle.config.ts implements
resolveDatabaseUrl() to fall back to individual PG*/RAILWAY_TCP_PROXY_* vars;
replicate that logic in runtime by extracting or reusing resolveDatabaseUrl for
application startup so env validation and DB connections use the same fallback.
Update server/utils/env.ts to validate/process the DB URL via the same
resolveDatabaseUrl (or a new shared getDatabaseUrl helper) instead of only
checking process.env.DATABASE_URL, and update server/utils/db.ts to call that
helper when constructing the DB client (replace direct use of env.DATABASE_URL).
Ensure the helper preserves encoding/port/user/password behavior and throws the
same descriptive error when no host can be resolved so migrations and runtime
behave identically.
…date privacy policy
…ent across blog, catalog, docs, and roadmap pages
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/pages/roadmap.vue (1)
2-26:⚠️ Potential issue | 🔴 CriticalMissing
Githubimport causes build error.The
Githubicon was removed from the imports but is still used at line 712 in the template (<Github class="h-3.5 w-3.5" />). This will cause a build/runtime error.🐛 Proposed fix to restore import
import { ArrowRight, Check, Hammer, Telescope, Star, ChevronRight, Layers, LogIn, Palette, Briefcase, Users, GitBranch, FileText, Globe, ClipboardList, LayoutDashboard, Search, ScanText, Sparkles, MessageSquare, Mail, Shield, + Github, type LucideIcon, } from 'lucide-vue-next'🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/roadmap.vue` around lines 2 - 26, The build fails because the Github icon was removed from the import list but is still referenced in the template as <Github />, so restore the Github symbol to the lucide-vue-next import statement (the same import block that currently lists ArrowRight, Check, Hammer, Telescope, etc.) so the template's <Github class="h-3.5 w-3.5" /> resolves at build/runtime.
🧹 Nitpick comments (6)
app/pages/roadmap.vue (1)
49-49: Unusedsessionvariable after navigation refactor.The
sessionvariable is fetched but not used sincePublicNavBarhandles session state.♻️ Proposed fix to remove dead code
-const { data: session } = await authClient.useSession(useFetch) - // ─── Roadmap data ──────────────────────────────────────────🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/roadmap.vue` at line 49, Remove the dead session fetch by deleting the unused const { data: session } = await authClient.useSession(useFetch) call (and any now-unused imports like useFetch if applicable); PublicNavBar handles session state so no local session fetch is needed—ensure you only remove that line and related unused bindings, leaving other authClient usages intact.app/pages/catalog/index.vue (1)
37-37: Unusedsessionvariable after navigation refactor.The
sessionvariable is fetched but no longer used in this component since the navigation was replaced withPublicNavBar(which handles its own session fetching). This adds an unnecessary API call.♻️ Proposed fix to remove dead code
-const { data: session } = await authClient.useSession(useFetch) - // ─── Fetch all catalog items ──────────────────────🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/catalog/index.vue` at line 37, The call that assigns const { data: session } = await authClient.useSession(useFetch) is now dead after the navigation refactor; remove that line (and any now-unused local variable or import related to session/useSession) so the unnecessary session fetch and API call are eliminated; search for authClient.useSession or the session binding in this component (index.vue) and delete the assignment and any unused references/imports (e.g., useFetch or session) to keep the component clean.app/pages/blog/index.vue (1)
31-31: Unusedsessionvariable after navigation refactor.Same issue as other pages - the
sessionvariable is fetched but not used sincePublicNavBarhandles session state internally.♻️ Proposed fix to remove dead code
-const { data: session } = await authClient.useSession(useFetch) - const { data: posts } = await useAsyncData('blog-posts', () =>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/blog/index.vue` at line 31, Remove the dead fetch of session: delete the unused const { data: session } = await authClient.useSession(useFetch) call (and any now-unused imports related only to that call) since PublicNavBar manages session state; ensure no other code references session/useSession in this file (e.g., check for useFetch/authClient imports) and remove them if unused.app/pages/blog/[...slug].vue (1)
63-63: Unusedsessionvariable after navigation refactor.The
sessionvariable is fetched but no longer used in this component sincePublicNavBarhandles its own session state.♻️ Proposed fix to remove dead code
-const { data: session } = await authClient.useSession(useFetch) </script>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/blog/`[...slug].vue at line 63, Remove the now-unused session retrieval: delete the line that calls authClient.useSession (const { data: session } = await authClient.useSession(useFetch)) since PublicNavBar manages session state; also remove any related unused imports or variables tied only to that call to avoid dead code. Ensure no other code in this component references session before removing.app/pages/docs/[...slug].vue (1)
139-172: Consider usingPublicNavBarfor consistency.The docs index page (
docs/index.vue) was updated to use the sharedPublicNavBarcomponent, but this slug page still uses inline navigation markup. For maintainability and visual consistency, consider updating this file to also usePublicNavBar.♻️ Suggested refactor to use PublicNavBar
- <!-- ───── Nav ───── --> - <nav - class="fixed inset-x-0 top-0 z-50 border-b border-white/[0.06] bg-[`#09090b`]/80 backdrop-blur-xl" - > - <div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-6"> - <NuxtLink to="/" class="text-[15px] font-semibold tracking-tight">Reqcore</NuxtLink> - <div class="flex items-center gap-5 text-[13px] text-white/60"> - <NuxtLink to="/roadmap" class="transition hover:text-white">Roadmap</NuxtLink> - <NuxtLink to="/catalog" class="transition hover:text-white">Features</NuxtLink> - <NuxtLink to="/blog" class="transition hover:text-white">Blog</NuxtLink> - <NuxtLink to="/docs" class="text-white transition">Docs</NuxtLink> - <a - href="https://github.com/reqcore-inc/reqcore" - target="_blank" - class="transition hover:text-white" - > - <Github class="size-4" /> - </a> - <NuxtLink - v-if="session?.user" - to="/dashboard" - class="rounded-md bg-white/10 px-3 py-1 text-white transition hover:bg-white/15" - > - Dashboard - </NuxtLink> - <NuxtLink - v-else - to="/auth/sign-in" - class="rounded-md bg-white/10 px-3 py-1 text-white transition hover:bg-white/15" - > - Sign In - </NuxtLink> - </div> - </div> - </nav> + <!-- ───── Nav ───── --> + <PublicNavBar active-page="docs" />You would also need to remove the
Githubimport from lucide-vue-next since it would no longer be used in this file.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/pages/docs/`[...slug].vue around lines 139 - 172, Replace the inline navigation block with the shared PublicNavBar component to match docs/index.vue: remove the entire <nav>…</nav> markup in this file and add the PublicNavBar component (ensure it's imported and registered as used in the template), preserve any props or reactive references needed (e.g., session) by passing them to PublicNavBar if required, and delete the now-unused Github import from lucide-vue-next; verify NuxtLink usage is handled by PublicNavBar so no duplicate links remain.app/components/ConsentBanner.vue (1)
14-16: Consider adding accessibility attributes for the consent banner.For GDPR consent banners, screen reader users benefit from proper ARIA attributes. Consider adding
role="dialog",aria-modal="false", andaria-labelledbyto make the banner more accessible.♿ Suggested accessibility improvements
<div v-if="needsConsent" + role="dialog" + aria-modal="false" + aria-labelledby="consent-title" + aria-describedby="consent-description" class="fixed bottom-4 right-4 z-50 max-w-sm rounded-lg border border-white/10 bg-zinc-900/95 px-4 py-3 shadow-2xl backdrop-blur-md sm:right-6 sm:bottom-6" > - <p class="mb-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">A small ask</p> - <p class="text-[13px] leading-relaxed text-white/70"> + <p id="consent-title" class="mb-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">A small ask</p> + <p id="consent-description" class="text-[13px] leading-relaxed text-white/70">🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/components/ConsentBanner.vue` around lines 14 - 16, The consent banner rendered in ConsentBanner.vue (the <div v-if="needsConsent" ...> root) lacks ARIA attributes; update that root element to include role="dialog", aria-modal="false", and aria-labelledby referencing the id of the visible banner title (create or use an id like consentBannerTitle) and optionally aria-describedby for descriptive text; ensure the title element inside the component has the matching id so screen readers announce the banner correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/components/PublicNavBar.vue`:
- Around line 55-68: The Blog and Docs NuxtLink entries use hardcoded paths
(to="/blog" and to="/docs") while other links use localePath(), causing
inconsistent locale routing; update the two NuxtLink components to use
localePath('blog') and localePath('docs') (or localePath({ name: 'blog' } if
your routes are named) for their "to" bindings, keep the existing :class logic
and activePage checks, and ensure localePath is imported/available in the
component where NuxtLink, activePage, and other navigation links are defined.
In `@app/composables/usePostHogIdentity.ts`:
- Around line 17-34: The watchers in usePostHogIdentity (and the plugin
callbacks in posthog-identity.client.ts that call
posthog.identify()/posthog.group()) send identity to PostHog immediately because
of immediate: true and no consent check; update usePostHogIdentity to read
useAnalyticsConsent() and only call $posthogIdentifyUser/$posthogSetOrganization
when consent is explicitly granted (and call $posthogReset when consent is
revoked), or remove immediate: true and trigger identification when consent
becomes true; also update the plugin callbacks that call
posthog.identify()/posthog.group() to first check useAnalyticsConsent() (or a
passed consent flag) and skip or reset identity calls when consent is not
granted.
In `@content/docs/6.legal/1.privacy-policy.md`:
- Around line 55-76: The privacy text and implementation mismatch because
usePostHogIdentity.ts currently calls posthog.identify(user.id, { name,
createdAt, orgId, orgSlug }) before consent; change the implementation to defer
any posthog.identify or identify-like calls until explicit analytics consent is
granted (use the app's consent state), stop sending direct PII (user.id, name,
createdAt) and organization identifiers until consent is given, and instead
either send a consent-gated anonymized identifier or omit identity metadata
entirely; ensure the code paths in usePostHogIdentity.ts that reference
posthog.identify and properties user.id, name, createdAt, orgId, orgSlug are
updated to check consent first and only transmit allowed anonymized data after
consent.
---
Outside diff comments:
In `@app/pages/roadmap.vue`:
- Around line 2-26: The build fails because the Github icon was removed from the
import list but is still referenced in the template as <Github />, so restore
the Github symbol to the lucide-vue-next import statement (the same import block
that currently lists ArrowRight, Check, Hammer, Telescope, etc.) so the
template's <Github class="h-3.5 w-3.5" /> resolves at build/runtime.
---
Nitpick comments:
In `@app/components/ConsentBanner.vue`:
- Around line 14-16: The consent banner rendered in ConsentBanner.vue (the <div
v-if="needsConsent" ...> root) lacks ARIA attributes; update that root element
to include role="dialog", aria-modal="false", and aria-labelledby referencing
the id of the visible banner title (create or use an id like consentBannerTitle)
and optionally aria-describedby for descriptive text; ensure the title element
inside the component has the matching id so screen readers announce the banner
correctly.
In `@app/pages/blog/`[...slug].vue:
- Line 63: Remove the now-unused session retrieval: delete the line that calls
authClient.useSession (const { data: session } = await
authClient.useSession(useFetch)) since PublicNavBar manages session state; also
remove any related unused imports or variables tied only to that call to avoid
dead code. Ensure no other code in this component references session before
removing.
In `@app/pages/blog/index.vue`:
- Line 31: Remove the dead fetch of session: delete the unused const { data:
session } = await authClient.useSession(useFetch) call (and any now-unused
imports related only to that call) since PublicNavBar manages session state;
ensure no other code references session/useSession in this file (e.g., check for
useFetch/authClient imports) and remove them if unused.
In `@app/pages/catalog/index.vue`:
- Line 37: The call that assigns const { data: session } = await
authClient.useSession(useFetch) is now dead after the navigation refactor;
remove that line (and any now-unused local variable or import related to
session/useSession) so the unnecessary session fetch and API call are
eliminated; search for authClient.useSession or the session binding in this
component (index.vue) and delete the assignment and any unused
references/imports (e.g., useFetch or session) to keep the component clean.
In `@app/pages/docs/`[...slug].vue:
- Around line 139-172: Replace the inline navigation block with the shared
PublicNavBar component to match docs/index.vue: remove the entire <nav>…</nav>
markup in this file and add the PublicNavBar component (ensure it's imported and
registered as used in the template), preserve any props or reactive references
needed (e.g., session) by passing them to PublicNavBar if required, and delete
the now-unused Github import from lucide-vue-next; verify NuxtLink usage is
handled by PublicNavBar so no duplicate links remain.
In `@app/pages/roadmap.vue`:
- Line 49: Remove the dead session fetch by deleting the unused const { data:
session } = await authClient.useSession(useFetch) call (and any now-unused
imports like useFetch if applicable); PublicNavBar handles session state so no
local session fetch is needed—ensure you only remove that line and related
unused bindings, leaving other authClient usages intact.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 28e44969-8bf3-4475-a11b-b03912af0f30
📒 Files selected for processing (14)
app/app.vueapp/components/ConsentBanner.vueapp/components/PublicNavBar.vueapp/composables/useAnalyticsConsent.tsapp/composables/usePostHogIdentity.tsapp/pages/blog/[...slug].vueapp/pages/blog/index.vueapp/pages/catalog/index.vueapp/pages/docs/[...slug].vueapp/pages/docs/index.vueapp/pages/roadmap.vueapp/plugins/posthog-identity.client.tscontent/docs/6.legal/1.privacy-policy.mdnuxt.config.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- app/plugins/posthog-identity.client.ts
- nuxt.config.ts
| <NuxtLink | ||
| to="/blog" | ||
| class="rounded-md px-3 py-1.5 text-[13px] font-medium transition" | ||
| :class="activePage === 'blog' ? 'text-white' : 'text-surface-400 hover:text-white'" | ||
| > | ||
| Blog | ||
| </NuxtLink> | ||
| <NuxtLink | ||
| to="/docs" | ||
| class="rounded-md px-3 py-1.5 text-[13px] font-medium transition" | ||
| :class="activePage === 'docs' ? 'text-white' : 'text-surface-400 hover:text-white'" | ||
| > | ||
| Docs | ||
| </NuxtLink> |
There was a problem hiding this comment.
Inconsistent use of localePath() for navigation links.
The Blog and Docs links use hardcoded paths (to="/blog" and to="/docs") while other navigation links (Features, Open Positions, Roadmap) use localePath(). This inconsistency could cause issues if additional locales are configured.
♻️ Proposed fix for consistent localization
<NuxtLink
- to="/blog"
+ :to="localePath('/blog')"
class="rounded-md px-3 py-1.5 text-[13px] font-medium transition"
:class="activePage === 'blog' ? 'text-white' : 'text-surface-400 hover:text-white'"
>
Blog
</NuxtLink>
<NuxtLink
- to="/docs"
+ :to="localePath('/docs')"
class="rounded-md px-3 py-1.5 text-[13px] font-medium transition"
:class="activePage === 'docs' ? 'text-white' : 'text-surface-400 hover:text-white'"
>
Docs
</NuxtLink>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <NuxtLink | |
| to="/blog" | |
| class="rounded-md px-3 py-1.5 text-[13px] font-medium transition" | |
| :class="activePage === 'blog' ? 'text-white' : 'text-surface-400 hover:text-white'" | |
| > | |
| Blog | |
| </NuxtLink> | |
| <NuxtLink | |
| to="/docs" | |
| class="rounded-md px-3 py-1.5 text-[13px] font-medium transition" | |
| :class="activePage === 'docs' ? 'text-white' : 'text-surface-400 hover:text-white'" | |
| > | |
| Docs | |
| </NuxtLink> | |
| <NuxtLink | |
| :to="localePath('/blog')" | |
| class="rounded-md px-3 py-1.5 text-[13px] font-medium transition" | |
| :class="activePage === 'blog' ? 'text-white' : 'text-surface-400 hover:text-white'" | |
| > | |
| Blog | |
| </NuxtLink> | |
| <NuxtLink | |
| :to="localePath('/docs')" | |
| class="rounded-md px-3 py-1.5 text-[13px] font-medium transition" | |
| :class="activePage === 'docs' ? 'text-white' : 'text-surface-400 hover:text-white'" | |
| > | |
| Docs | |
| </NuxtLink> |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/components/PublicNavBar.vue` around lines 55 - 68, The Blog and Docs
NuxtLink entries use hardcoded paths (to="/blog" and to="/docs") while other
links use localePath(), causing inconsistent locale routing; update the two
NuxtLink components to use localePath('blog') and localePath('docs') (or
localePath({ name: 'blog' } if your routes are named) for their "to" bindings,
keep the existing :class logic and activePage checks, and ensure localePath is
imported/available in the component where NuxtLink, activePage, and other
navigation links are defined.
…graceful shutdown
…tion in PostHog integration
There was a problem hiding this comment.
Pull request overview
This PR integrates PostHog for user analytics with full GDPR consent management, replacing the previous Plausible analytics. It adds a consent banner, server-side PostHog utilities, client-side identity syncing with Better Auth, and a reusable PublicNavBar component that consolidates duplicated inline navbars across public pages.
Changes:
- Added PostHog client (via
@posthog/nuxt) and server-side (posthog-node) analytics integration with opt-in-by-default consent management - Created composables and a client plugin to sync Better Auth session/org identity with PostHog, gated on user consent
- Extracted duplicated inline navigation across public pages into a shared
PublicNavBarcomponent; added aresolveDatabaseUrl()helper for Railway PR environments in bothdrizzle.config.tsandserver/scripts/seed.ts
Reviewed changes
Copilot reviewed 19 out of 20 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
server/utils/posthog.ts |
Lazily-initialized singleton server-side PostHog client with graceful shutdown |
server/plugins/posthog.ts |
Nitro plugin that calls shutdownServerPostHog() on server close |
server/scripts/seed.ts |
Adds resolveDatabaseUrl() helper for Railway preview env DB URL resolution |
drizzle.config.ts |
Same resolveDatabaseUrl() logic as seed.ts to support Railway preview envs |
package.json |
Adds @posthog/nuxt and posthog-node dependencies; adds @posthog/cli override |
package-lock.json |
Lock file updates from adding PostHog packages |
nuxt.config.ts |
Configures PostHog module with privacy settings; removes Plausible |
app/composables/useAnalyticsConsent.ts |
New composable managing consent state in localStorage and PostHog opt-in/out |
app/composables/usePostHogIdentity.ts |
Syncs Better Auth session/org with PostHog identity, gated on consent |
app/plugins/posthog-identity.client.ts |
Client plugin applying consent, URL sanitization, and providing identity helpers |
app/components/ConsentBanner.vue |
GDPR-compliant consent banner UI with accept/decline options |
app/components/PublicNavBar.vue |
New reusable nav component replacing duplicated inline navbars |
app/app.vue |
Calls usePostHogIdentity() and renders ConsentBanner |
app/pages/roadmap.vue |
Replaces inline nav with <PublicNavBar> |
app/pages/docs/index.vue |
Replaces inline nav with <PublicNavBar> |
app/pages/docs/[...slug].vue |
Adds Legal section icon for sidebar |
app/pages/catalog/index.vue |
Replaces inline nav with <PublicNavBar> |
app/pages/blog/index.vue |
Replaces inline nav with <PublicNavBar> |
app/pages/blog/[...slug].vue |
Replaces inline nav with <PublicNavBar> |
content/docs/6.legal/1.privacy-policy.md |
New privacy policy document with GDPR/CCPA coverage |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -1,5 +1,5 @@ | |||
| <script setup lang="ts"> | |||
| import { ArrowLeft, Github, Calendar, User } from 'lucide-vue-next' | |||
| import { ArrowLeft, Calendar, User } from 'lucide-vue-next' | |||
There was a problem hiding this comment.
Similarly, after removing the inline navbar in blog/[...slug].vue, the session variable fetched via authClient.useSession(useFetch) (which was only used by the navbar) is now unused. It should be removed since PublicNavBar fetches the session itself.
| * | ||
| * By default, PostHog captures events. Users can opt out via this composable. | ||
| * The consent state is persisted in localStorage. |
There was a problem hiding this comment.
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.ts which sets opt_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."
| // Must match the key used in useAnalyticsConsent.ts | ||
| const CONSENT_STORAGE_KEY = 'reqcore-analytics-consent' |
There was a problem hiding this comment.
The localStorage key 'reqcore-analytics-consent' is duplicated as both CONSENT_KEY in useAnalyticsConsent.ts (line 10) and CONSENT_STORAGE_KEY in posthog-identity.client.ts (line 15). The plugin's comment even says "Must match the key used in useAnalyticsConsent.ts", making this a fragile coupling. If one key is updated, the other must also be changed manually to avoid a silent mismatch that would break consent detection. Consider exporting the constant from a shared location (e.g., a shared constants file or from the composable itself) and importing it in the plugin.
| 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.`, | ||
| ) | ||
| } |
There was a problem hiding this comment.
The resolveDatabaseUrl() function is duplicated verbatim in both drizzle.config.ts and server/scripts/seed.ts. The only difference is the error handling when no host is found (drizzle.config throws, seed.ts returns an empty string and exits with process.exit). This shared logic should be extracted into a utility module (e.g., server/utils/resolveDatabaseUrl.ts or a shared config helper) to avoid the risk of the two implementations diverging.
| <script setup lang="ts"> | ||
| import { | ||
| Github, | ||
| ChevronRight, | ||
| ChevronDown, | ||
| X, |
There was a problem hiding this comment.
After the inline navbar was replaced with <PublicNavBar active-page="features" />, the session variable fetched on line 37 via authClient.useSession(useFetch) is no longer used in this file's template or script. The PublicNavBar handles session detection internally. Remove this unused fetch to avoid the extra network request.
| <script setup lang="ts"> | ||
| import { | ||
| BookOpen, | ||
| Github, | ||
| ChevronRight, | ||
| Download, | ||
| Rocket, |
There was a problem hiding this comment.
After replacing the inline navbar with <PublicNavBar active-page="docs" />, the session variable fetched via authClient.useSession(useFetch) at line 52 is no longer referenced in this file's template or script. Remove this unused fetch; PublicNavBar handles session detection itself.
| <script setup lang="ts"> | ||
| import { | ||
| ArrowRight, | ||
| Github, | ||
| Check, |
There was a problem hiding this comment.
After replacing the inline navbar with <PublicNavBar active-page="roadmap" />, the session variable fetched at line 49 via authClient.useSession(useFetch) is no longer referenced in this file's template or script. The PublicNavBar handles session detection internally. This dead fetch should be removed.
| "minimatch": "10.2.4", | ||
| "@isaacs/brace-expansion": "5.0.1", | ||
| "axios": ">=1.13.6", | ||
| "@posthog/cli": "0.7.1", |
There was a problem hiding this comment.
The package.json overrides section specifies "@posthog/cli": "0.7.1", but the package-lock.json resolves @posthog/cli to version 0.5.30. The lock file was not regenerated after the override was added, so the override has no effect. Either run npm install to regenerate the lockfile, or remove the override if version 0.5.30 is acceptable.
| @@ -1,5 +1,5 @@ | |||
| <script setup lang="ts"> | |||
| import { ArrowRight, BookOpen, Github, Star, ChevronRight } from 'lucide-vue-next' | |||
| import { ArrowRight, BookOpen, Star, ChevronRight } from 'lucide-vue-next' | |||
There was a problem hiding this comment.
The Github icon import was removed but the session variable fetched via authClient.useSession(useFetch) on line 31 is now unused — the inline navbar that referenced session was replaced by <PublicNavBar>, which handles session fetching internally. This dead fetch can be safely removed from this file to avoid an unnecessary network request per page load.
Summary
Type of change
Validation
DCO
Signed-off-by) viagit commit -sSummary by CodeRabbit
New Features
Chores