| name | posthog |
| description | Implement PostHog analytics for PhotoVault with dual tracking (client + server). Use when working with event tracking, funnel analysis, user identification, TypeScript event schemas, ad-blocker-proof server-side tracking, or debugging missing analytics data. Includes PhotoVault event definitions and privacy defaults. |
⚠️ MANDATORY WORKFLOW - DO NOT SKIP
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.mdPresent 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.
PostHog Analytics Integration
Core Principles
Dual Tracking is Non-Negotiable
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.
TypeScript Event Schemas Prevent Chaos
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
})
Identify Users Early and Consistently
posthog.identify(user.id, {
user_type: 'photographer' | 'client',
signup_date: user.created_at,
})
Anti-Patterns
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')
Client-Side Setup
// 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)
}
Server-Side Setup
// 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()
}
usePageView Hook
// 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])
}
PhotoVault Configuration
Critical Server-Side Events
| 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 |
Environment Variables
NEXT_PUBLIC_POSTHOG_KEY=phc_...
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com
POSTHOG_API_KEY=phc_... # Same key, server-side
Free Tier Limit
PostHog free tier: 1 million events/month. Set alert at 800K.
Debugging Checklist
- Check PostHog Dashboard → Activity → Live Events
- Verify user identification with
posthog.debug()in dev - Check server-side events have
$source: 'server'property - Verify flush is called in serverless functions
- Test with ad blocker enabled (server events should still work)