Implement PostHog analytics for PhotoVault with dual tracking (client + server)...
When this skill activates, you MUST follow the expert workflow before writing any code:
Spawn Domain Expert using the Task tool with this prompt:
Read the expert prompt at: C:\Users\natha\Stone-Fence-Brain\VENTURES\PhotoVault\claude\experts\posthog-expert.md
Then research the codebase and write an implementation plan to: docs/claude/plans/posthog-[task-name]-plan.md
Task: [describe the user's request]
Spawn QA Critic after expert returns, using Task tool:
Read the QA critic prompt at: C:\Users\natha\Stone-Fence-Brain\VENTURES\PhotoVault\claude\experts\qa-critic-expert.md
Review the plan at: docs/claude/plans/posthog-[task-name]-plan.md
Write critique to: docs/claude/plans/posthog-[task-name]-critique.md
Present BOTH plan and critique to user - wait for approval before implementing
DO NOT read files and start coding. DO NOT rationalize that "this is simple." Follow the workflow.
Ad blockers block PostHog's client-side library in 30%+ of browsers. Critical funnel events MUST use server-side tracking.
// CRITICAL EVENTS → Server-side (can't be blocked)
// - Signup, payment, churn, subscription changes
// - Anything that affects revenue attribution
// ENGAGEMENT EVENTS → Client-side (okay if some are blocked)
// - Page views, button clicks, gallery browsing
Rule of thumb: If losing 30% of this event would break your funnel analysis, it MUST be server-side.
Without strict types, you'll end up with gallery_id, galleryId, gallery-id, and GalleryId in your data.
// src/types/analytics.ts - EVERY event name and properties defined here
import { GalleryViewedEvent } from '@/types/analytics'
trackEvent<GalleryViewedEvent>('gallery_viewed', {
gallery_id: '123', // Type error if wrong name
})
posthog.identify(user.id, {
user_type: 'photographer' | 'client',
signup_date: user.created_at,
})
Only client-side tracking for critical events
// WRONG: 30%+ blocked by ad blockers
posthog.capture('payment_completed', { amount: 100 })
// RIGHT: Server-side for critical funnel events
await posthog.capture({
distinctId: userId,
event: 'payment_completed',
properties: { amount: 100, $source: 'server' }
})
Inconsistent property naming
// WRONG: Different properties in PostHog
{ gallery_id: '123' }
{ galleryId: '123' }
{ GalleryId: '123' }
// RIGHT: Use TypeScript types
trackEvent<GalleryViewedEvent>('gallery_viewed', { gallery_id: '123' })
Forgetting to flush server-side events
// WRONG: Events lost if process exits
posthog.capture({ distinctId, event, properties })
// RIGHT: Flush in serverless
posthog.capture({ distinctId, event, properties })
await posthog.flush()
Blocking user actions on analytics
// WRONG: User waits
await posthog.capture('form_submitted')
router.push('/success')
// RIGHT: Fire and forget
posthog.capture('form_submitted')
router.push('/success')
// src/lib/analytics/client.ts
import posthog from 'posthog-js'
export function initPostHog() {
if (typeof window === 'undefined') return
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com',
capture_pageviews: true,
respect_dnt: true,
disable_session_recording: true,
persistence: 'localStorage',
})
}
export function identifyUser(userId: string, properties: Record<string, unknown>) {
posthog.identify(userId, properties)
}
export function resetAnalytics() {
posthog.reset()
}
export function trackEvent(eventName: string, properties?: Record<string, unknown>) {
posthog.capture(eventName, properties)
}
// src/lib/analytics/server.ts
import { PostHog } from 'posthog-node'
let posthogClient: PostHog | null = null
function getPostHogClient(): PostHog {
if (!posthogClient) {
posthogClient = new PostHog(process.env.POSTHOG_API_KEY!, {
host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com',
flushAt: 1,
flushInterval: 0,
})
}
return posthogClient
}
export async function trackServerEvent(
userId: string,
eventName: string,
properties?: Record<string, unknown>
) {
const client = getPostHogClient()
client.capture({
distinctId: userId,
event: eventName,
properties: { ...properties, $source: 'server' },
})
await client.flush()
}
// src/hooks/useAnalytics.ts
'use client'
import { useEffect, useRef } from 'react'
import { trackEvent } from '@/lib/analytics/client'
export function usePageView(pageName: string, properties?: Record<string, unknown>) {
const startTimeRef = useRef<number>(Date.now())
const hasTrackedRef = useRef<boolean>(false)
useEffect(() => {
if (!hasTrackedRef.current) {
trackEvent(`${pageName}_viewed`, properties)
hasTrackedRef.current = true
startTimeRef.current = Date.now()
}
return () => {
const durationSeconds = Math.round((Date.now() - startTimeRef.current) / 1000)
trackEvent(`${pageName}_left`, { ...properties, duration_seconds: durationSeconds })
}
}, [pageName])
}
| Event | Trigger | Why Critical |
|---|---|---|
photographer_signed_up |
Signup API | Top of funnel |
photographer_connected_stripe |
Connect callback | Conversion milestone |
client_payment_completed |
Stripe webhook | Revenue event |
client_payment_failed |
Stripe webhook | Churn risk signal |
photographer_churned |
Cancel subscription | Retention metric |
NEXT_PUBLIC_POSTHOG_KEY=phc_...
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com
POSTHOG_API_KEY=phc_... # Same key, server-side
PostHog free tier: 1 million events/month. Set alert at 800K.
posthog.debug() in dev$source: 'server' property