Analytics

Comprehensive guide to analytics in ShipKit

Analytics

This document provides a comprehensive guide to analytics in ShipKit, including setup, tracking, and reporting.

Analytics Setup

Vercel Analytics

// src/app/layout.tsx
import { Analytics } from '@vercel/analytics/react'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  )
}

PostHog Setup

// src/lib/analytics/posthog.ts
import posthog from 'posthog-js'
import { env } from '@/env.mjs'

export function initPostHog() {
  if (typeof window !== 'undefined' && env.NEXT_PUBLIC_POSTHOG_KEY) {
    posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, {
      api_host: env.NEXT_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com',
      capture_pageview: false, // Handle manually
      persistence: 'localStorage',
      autocapture: true,
      session_recording: {
        maskAllInputs: true,
        maskInputOptions: {
          password: true,
          email: true,
          credit_card: true,
        },
      },
    })
  }
}

Environment Configuration

// src/env.mjs
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
  client: {
    NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
    NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
  },
  server: {
    POSTHOG_API_KEY: z.string(),
    POSTHOG_PROJECT_ID: z.string(),
  },
  // ...
})

Event Tracking

Analytics Provider

// src/lib/analytics/provider.ts
import posthog from 'posthog-js'
import { type User } from '@prisma/client'

interface EventProperties {
  [key: string]: unknown
}

export class Analytics {
  private static instance: Analytics
  private initialized = false

  private constructor() {
    if (typeof window !== 'undefined') {
      this.initialized = true
      initPostHog()
    }
  }

  static getInstance(): Analytics {
    if (!Analytics.instance) {
      Analytics.instance = new Analytics()
    }
    return Analytics.instance
  }

  identify(user: User) {
    if (!this.initialized) return

    posthog.identify(user.id, {
      email: user.email,
      name: user.name,
      role: user.role,
      createdAt: user.createdAt,
    })
  }

  track(event: string, properties: EventProperties = {}) {
    if (!this.initialized) return

    posthog.capture(event, {
      ...properties,
      timestamp: new Date().toISOString(),
    })
  }

  page(properties: EventProperties = {}) {
    if (!this.initialized) return

    posthog.capture('$pageview', {
      ...properties,
      url: window.location.href,
      path: window.location.pathname,
      referrer: document.referrer,
      title: document.title,
    })
  }

  reset() {
    if (!this.initialized) return

    posthog.reset()
  }
}

export const analytics = Analytics.getInstance()

Event Hooks

// src/hooks/use-analytics.ts
import { useEffect } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
import { analytics } from '@/lib/analytics/provider'

export function usePageView() {
  const pathname = usePathname()
  const searchParams = useSearchParams()

  useEffect(() => {
    if (pathname) {
      analytics.page({
        path: pathname,
        search: searchParams?.toString(),
      })
    }
  }, [pathname, searchParams])
}

export function useTrackEvent() {
  return {
    track: (event: string, properties = {}) => {
      analytics.track(event, properties)
    },
  }
}

Event Components

// src/components/analytics/track-event.tsx
'use client'

import { type ReactNode } from 'react'
import { analytics } from '@/lib/analytics/provider'

interface TrackEventProps {
  event: string
  properties?: Record<string, unknown>
  children: ReactNode
}

export function TrackEvent({
  event,
  properties,
  children,
}: TrackEventProps) {
  const handleClick = () => {
    analytics.track(event, properties)
  }

  return (
    <div onClick={handleClick}>
      {children}
    </div>
  )
}

Server-Side Tracking

API Route Tracking

// src/app/api/v1/posts/route.ts
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { analytics } from '@/lib/analytics/provider'
import { db } from '@/server/db'

export async function POST(req: Request) {
  try {
    const session = await getServerSession()
    if (!session?.user) {
      return new NextResponse('Unauthorized', { status: 401 })
    }

    const json = await req.json()
    const post = await db.post.create({
      data: {
        ...json,
        authorId: session.user.id,
      },
    })

    // Track post creation
    analytics.track('post_created', {
      postId: post.id,
      userId: session.user.id,
      title: post.title,
    })

    return NextResponse.json(post, { status: 201 })
  } catch (error) {
    return new NextResponse('Internal Error', { status: 500 })
  }
}

Server Action Tracking

// src/server/actions/posts.ts
'use server'

import { revalidatePath } from 'next/cache'
import { getServerSession } from 'next-auth'
import { analytics } from '@/lib/analytics/provider'
import { db } from '@/server/db'

export async function createPost(data: {
  title: string
  content?: string
}) {
  try {
    const session = await getServerSession()
    if (!session?.user) {
      throw new Error('Unauthorized')
    }

    const post = await db.post.create({
      data: {
        ...data,
        authorId: session.user.id,
      },
    })

    // Track post creation
    analytics.track('post_created', {
      postId: post.id,
      userId: session.user.id,
      title: post.title,
    })

    revalidatePath('/dashboard/posts')
    return { data: post }
  } catch (error) {
    return { error: 'Failed to create post' }
  }
}

Custom Events

Event Definitions

// src/lib/analytics/events.ts
export const AnalyticsEvents = {
  // Auth Events
  SIGN_UP: 'sign_up',
  SIGN_IN: 'sign_in',
  SIGN_OUT: 'sign_out',
  PASSWORD_RESET: 'password_reset',

  // Post Events
  POST_CREATED: 'post_created',
  POST_UPDATED: 'post_updated',
  POST_DELETED: 'post_deleted',
  POST_PUBLISHED: 'post_published',

  // User Events
  PROFILE_UPDATED: 'profile_updated',
  SETTINGS_UPDATED: 'settings_updated',

  // Feature Usage
  FEATURE_USED: 'feature_used',
  SEARCH_PERFORMED: 'search_performed',
  FILTER_APPLIED: 'filter_applied',
  SORT_APPLIED: 'sort_applied',

  // Error Events
  ERROR_OCCURRED: 'error_occurred',
  API_ERROR: 'api_error',
  VALIDATION_ERROR: 'validation_error',
} as const

export type AnalyticsEvent = keyof typeof AnalyticsEvents

export interface EventProperties {
  // Common Properties
  userId?: string
  timestamp?: string
  source?: string

  // Auth Properties
  provider?: string
  method?: string

  // Content Properties
  postId?: string
  title?: string
  category?: string

  // Error Properties
  error?: string
  errorCode?: string
  errorMessage?: string

  // Feature Properties
  featureId?: string
  featureName?: string
  action?: string

  // Custom Properties
  [key: string]: unknown
}

Event Tracking

// src/lib/analytics/track.ts
import { analytics } from './provider'
import { type AnalyticsEvent, type EventProperties } from './events'

export function trackEvent(
  event: AnalyticsEvent,
  properties: EventProperties = {}
) {
  analytics.track(event, {
    ...properties,
    timestamp: new Date().toISOString(),
  })
}

export function trackError(
  error: Error,
  context: Record<string, unknown> = {}
) {
  analytics.track('error_occurred', {
    error: error.name,
    message: error.message,
    stack: error.stack,
    ...context,
  })
}

export function trackFeatureUsage(
  featureId: string,
  action: string,
  properties: EventProperties = {}
) {
  analytics.track('feature_used', {
    featureId,
    action,
    ...properties,
  })
}

Analytics Reporting

Data Export

// src/lib/analytics/export.ts
import { PostHog } from 'posthog-node'
import { env } from '@/env.mjs'

const client = new PostHog(env.POSTHOG_API_KEY)

export async function exportEvents(
  startDate: Date,
  endDate: Date
) {
  const events = await client.events.list({
    from: startDate.toISOString(),
    to: endDate.toISOString(),
  })

  return events
}

export async function exportUsers() {
  const users = await client.persons.list()
  return users
}

export async function exportFunnels() {
  const funnels = await client.funnels.list()
  return funnels
}

Report Generation

// src/lib/analytics/reports.ts
import { PostHog } from 'posthog-node'
import { env } from '@/env.mjs'

const client = new PostHog(env.POSTHOG_API_KEY)

interface DateRange {
  startDate: Date
  endDate: Date
}

export async function generateUserReport(
  dateRange: DateRange
) {
  const { startDate, endDate } = dateRange

  const [signups, activeUsers, retention] = await Promise.all([
    client.events.list({
      event: 'sign_up',
      from: startDate.toISOString(),
      to: endDate.toISOString(),
    }),
    client.trends.active_users({
      from: startDate.toISOString(),
      to: endDate.toISOString(),
    }),
    client.retention.list({
      from: startDate.toISOString(),
      to: endDate.toISOString(),
    }),
  ])

  return {
    signups: signups.length,
    activeUsers,
    retention,
  }
}

export async function generateFeatureReport(
  featureId: string,
  dateRange: DateRange
) {
  const { startDate, endDate } = dateRange

  const events = await client.events.list({
    event: 'feature_used',
    properties: {
      featureId,
    },
    from: startDate.toISOString(),
    to: endDate.toISOString(),
  })

  const usageByDay = events.reduce((acc, event) => {
    const date = new Date(event.timestamp).toISOString().split('T')[0]
    acc[date] = (acc[date] || 0) + 1
    return acc
  }, {} as Record<string, number>)

  return {
    totalUsage: events.length,
    usageByDay,
  }
}

Testing

Analytics Mocking

// src/lib/analytics/__mocks__/provider.ts
export const mockAnalytics = {
  identify: jest.fn(),
  track: jest.fn(),
  page: jest.fn(),
  reset: jest.fn(),
}

export const analytics = mockAnalytics

Event Testing

// src/lib/analytics/__tests__/track.test.ts
import { analytics } from '../provider'
import { trackEvent, trackError, trackFeatureUsage } from '../track'

jest.mock('../provider')

describe('Analytics Tracking', () => {
  beforeEach(() => {
    jest.clearAllMocks()
  })

  it('should track events with properties', () => {
    const event = 'sign_up'
    const properties = { userId: '123' }

    trackEvent(event, properties)

    expect(analytics.track).toHaveBeenCalledWith(
      event,
      expect.objectContaining({
        ...properties,
        timestamp: expect.any(String),
      })
    )
  })

  it('should track errors with context', () => {
    const error = new Error('Test error')
    const context = { userId: '123' }

    trackError(error, context)

    expect(analytics.track).toHaveBeenCalledWith(
      'error_occurred',
      expect.objectContaining({
        error: error.name,
        message: error.message,
        stack: error.stack,
        ...context,
      })
    )
  })

  it('should track feature usage', () => {
    const featureId = 'test-feature'
    const action = 'click'
    const properties = { userId: '123' }

    trackFeatureUsage(featureId, action, properties)

    expect(analytics.track).toHaveBeenCalledWith(
      'feature_used',
      expect.objectContaining({
        featureId,
        action,
        ...properties,
      })
    )
  })
})