数据埋点与用户分析

"没有数据就没有真相"——SaaS 产品的每一个决策都应该有数据支撑。埋点系统回答三个核心问题:用户从哪来(获客)、怎么用(激活/留存)、为什么付费(转化)。本章讲解 Next.js SaaS 中的分析体系。

1. 分析方案选型

1.1 方案对比

方案类型隐私价格推荐场景
PostHog全功能(事件+回放+Feature Flag)可自托管免费层慷慨✅ SaaS 首选
Vercel AnalyticsWeb 性能分析符合 GDPR随 Vercel性能监控
Plausible轻量网站分析隐私优先$9/月营销站
Mixpanel事件分析美国托管免费层产品分析
Google Analytics全功能隐私争议免费⚠️ GDPR 风险

1.2 推荐组合

层级工具用途
产品分析PostHog事件、漏斗、留存、Session Replay
性能监控Vercel AnalyticsWeb Vitals、路由性能
错误监控Sentry错误追踪(第 59 章)

1.3 数据分析架构

                    ┌──────────────────────────────────────┐
                    │          数据采集层                    │
                    │  ┌──────────┐  ┌──────────────────┐  │
                    │  │ 客户端SDK │  │ 服务端 SDK        │  │
                    │  │ posthog-js│  │ posthog-node     │  │
                    │  └────┬─────┘  └───────┬──────────┘  │
                    └───────┼────────────────┼─────────────┘
                            ↓                ↓
                    ┌──────────────────────────────────────┐
                    │          PostHog 平台                  │
                    │  Events → Persons → Groups            │
                    │     ↓         ↓         ↓             │
                    │  漏斗分析  留存分析  组织级分析          │
                    │  Session Replay  Feature Flags         │
                    └──────────────────────────────────────┘
                            ↓
                    ┌──────────────────────────────────────┐
                    │          数据输出                      │
                    │  Dashboard │ Webhook │ Data Warehouse  │
                    └──────────────────────────────────────┘

1.4 SaaS 产品关键指标体系

类别指标定义健康基准
获客Signup Rate访客 → 注册转化率2-5%
激活Activation Rate注册 → 完成核心动作20-40%
留存D7 Retention7 天后仍活跃的用户占比20-30%
留存D30 Retention30 天留存率10-15%
营收Trial-to-Paid试用 → 付费转化率15-25%
营收MRR Growth月度经常性收入增长10-20%
流失Churn Rate月度取消订阅占比< 5%
效率TTV (Time to Value)注册到第一次获得价值的时间越短越好

2. PostHog 集成

2.1 安装与初始化

// lib/analytics/posthog.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://us.i.posthog.com',
    person_profiles: 'identified_only',
    capture_pageview: false,  // 手动控制,避免 SPA 重复上报
    capture_pageleave: true,
  })
}

2.2 Provider 组件

// components/providers/posthog-provider.tsx
'use client'
 
import { useEffect } from 'react'
import { usePathname, useSearchParams } from 'next/navigation'
import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import { initPostHog } from '@/lib/analytics/posthog'
 
export function PostHogProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => { initPostHog() }, [])
 
  const pathname = usePathname()
  const searchParams = useSearchParams()
 
  useEffect(() => {
    if (pathname) posthog.capture('$pageview', { $current_url: window.location.href })
  }, [pathname, searchParams])
 
  return <PHProvider client={posthog}>{children}</PHProvider>
}

2.3 用户身份关联

// 登录后调用——将匿名行为与用户身份关联
export function identifyUser(user: { id: string; email: string; name: string }) {
  posthog.identify(user.id, {
    email: user.email,
    name: user.name,
  })
}
 
// 切换组织时更新 Group
export function setOrganization(org: { id: string; name: string; plan: string }) {
  posthog.group('organization', org.id, {
    name: org.name,
    plan: org.plan,
  })
}
 
// 退出登录
export function resetAnalytics() {
  posthog.reset()
}

2.4 在 Root Layout 集成

// app/layout.tsx
import { PostHogProvider } from '@/components/providers/posthog-provider'
import { Suspense } from 'react'
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Suspense fallback={null}>
          <PostHogProvider>
            {children}
          </PostHogProvider>
        </Suspense>
      </body>
    </html>
  )
}

为什么需要 Suspense? useSearchParams() 在 App Router 中要求包裹在 Suspense 内。不加会导致整个页面退化为客户端渲染。

2.5 用户属性与 Super Properties

// lib/analytics/properties.ts
import posthog from 'posthog-js'
 
// Super Properties:每次事件自动附带
export function setSuperProperties(props: {
  app_version: string
  environment: string
}) {
  posthog.register(props)
}
 
// 一次性属性:只设置一次(如首次注册渠道)
export function setOnceProperties(props: Record<string, any>) {
  posthog.register_once(props)
}
 
// 登录时设置完整上下文
export function setupAnalyticsContext(user: {
  id: string; email: string; name: string; plan: string
}, org: { id: string; name: string; plan: string }) {
  // 1. 身份关联
  posthog.identify(user.id, { email: user.email, name: user.name, plan: user.plan })
 
  // 2. 组织关联
  posthog.group('organization', org.id, { name: org.name, plan: org.plan })
 
  // 3. 全局属性
  setSuperProperties({
    app_version: process.env.NEXT_PUBLIC_APP_VERSION || 'unknown',
    environment: process.env.NODE_ENV,
  })
}

2.6 反向代理解决 Ad Blocker

Ad Blocker 会拦截发往 posthog.com 的请求。通过 Next.js rewrites 做反向代理可以绕过拦截:

// next.config.ts
const nextConfig = {
  async rewrites() {
    return [
      {
        source: '/ingest/static/:path*',
        destination: 'https://us-assets.i.posthog.com/static/:path*',
      },
      {
        source: '/ingest/:path*',
        destination: 'https://us.i.posthog.com/:path*',
      },
    ]
  },
}

然后初始化时指向本地路径:

posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
  api_host: '/ingest',  // 走反向代理
  ui_host: 'https://us.posthog.com',
})

这样 PostHog 请求与你的应用同域,Ad Blocker 无法区分。数据采集率可从 ~70% 提升至 ~95%。

3. 自定义事件埋点

3.1 事件命名规范

类别命名格式示例
页面浏览$pageviewPostHog 内置
功能使用feature_usedproject_created
转化事件conversion_xxxsubscription_started
错误事件error_xxxerror_payment_failed

3.2 关键业务事件

事件触发时机关键属性
signup_completed注册成功method(oauth/email)
onboarding_step引导步骤step, completed
project_created创建项目project_type
member_invited邀请成员role
subscription_started开始订阅plan, billing_period
feature_limit_hit触达功能限制feature, current_plan

3.3 类型安全的埋点封装

// lib/analytics/events.ts
import posthog from 'posthog-js'
 
type AnalyticsEvents = {
  signup_completed: { method: 'oauth' | 'email' | 'magic_link' }
  project_created: { project_type: string }
  subscription_started: { plan: string; billing_period: 'monthly' | 'yearly' }
  feature_limit_hit: { feature: string; current_plan: string }
}
 
export function track<E extends keyof AnalyticsEvents>(
  event: E,
  properties: AnalyticsEvents[E]
) {
  posthog.capture(event, properties)
}
 
// 使用——编译期检查事件名和属性
track('subscription_started', { plan: 'pro', billing_period: 'monthly' })

3.4 服务端埋点

// lib/analytics/server.ts
import { PostHog } from 'posthog-node'
 
const serverPostHog = new PostHog(process.env.POSTHOG_API_KEY!, {
  host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
  flushAt: 1,
  flushInterval: 0,
})
 
export function trackServerEvent(
  userId: string,
  event: string,
  properties?: Record<string, any>
) {
  serverPostHog.capture({ distinctId: userId, event, properties })
}
 
// 在 Server Action / Webhook 中使用
trackServerEvent(userId, 'subscription_started', { plan: 'pro' })

3.5 React Hook 封装

// lib/analytics/use-track.ts
'use client'
 
import { useCallback } from 'react'
import posthog from 'posthog-js'
import type { AnalyticsEvents } from './events'
 
export function useTrack() {
  return useCallback(<E extends keyof AnalyticsEvents>(
    event: E,
    properties: AnalyticsEvents[E]
  ) => {
    posthog.capture(event, properties)
  }, [])
}
 
// 组件中使用
function CreateProjectButton() {
  const track = useTrack()
 
  return (
    <Button onClick={async () => {
      const project = await createProject()
      track('project_created', { project_type: project.type })
    }}>
      New Project
    </Button>
  )
}

3.6 自动化埋点:表单提交追踪

// components/tracked-form.tsx
'use client'
 
import { useRef } from 'react'
import posthog from 'posthog-js'
 
interface TrackedFormProps extends React.FormHTMLAttributes<HTMLFormElement> {
  eventName: string
  eventProperties?: Record<string, any>
}
 
export function TrackedForm({ eventName, eventProperties, children, ...props }: TrackedFormProps) {
  const startTime = useRef(Date.now())
 
  return (
    <form
      {...props}
      onSubmit={(e) => {
        const duration = Date.now() - startTime.current
        posthog.capture(eventName, {
          ...eventProperties,
          form_duration_ms: duration,
        })
        props.onSubmit?.(e)
      }}
    >
      {children}
    </form>
  )
}
 
// 使用
<TrackedForm eventName="settings_updated" eventProperties={{ section: 'profile' }}>
  {/* 表单字段 */}
</TrackedForm>

3.7 Webhook 事件中的服务端埋点

// app/api/webhooks/stripe/route.ts
import { trackServerEvent } from '@/lib/analytics/server'
 
// 在 Stripe Webhook 中追踪支付事件
async function handleSubscriptionCreated(subscription: Stripe.Subscription) {
  const userId = await getUserIdByStripeCustomer(subscription.customer as string)
 
  trackServerEvent(userId, 'subscription_started', {
    plan: subscription.items.data[0]?.price.lookup_key,
    billing_period: subscription.items.data[0]?.price.recurring?.interval,
    amount: subscription.items.data[0]?.price.unit_amount,
    currency: subscription.currency,
  })
}
 
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
  const userId = await getUserIdByStripeCustomer(invoice.customer as string)
 
  trackServerEvent(userId, 'payment_failed', {
    amount: invoice.amount_due,
    attempt_count: invoice.attempt_count,
    next_attempt: invoice.next_payment_attempt,
  })
}

4. 漏斗分析

4.1 SaaS 核心漏斗

访问营销站 → 注册 → 完成引导 → 使用核心功能 → 升级付费
    100%      30%      20%         10%            3%
阶段对应事件优化目标
访问 → 注册$pageviewsignup_completed提升注册转化率
注册 → 激活signup_completedonboarding_completed减少引导流失
激活 → 留存onboarding_completed → 7日内回访提升 Aha Moment 到达率
留存 → 付费活跃用户 → subscription_started优化定价/价值呈现

4.2 Feature Flag 驱动增长

// PostHog Feature Flag 实践
import { useFeatureFlagEnabled } from 'posthog-js/react'
 
export function PricingPage() {
  const showNewPricing = useFeatureFlagEnabled('new-pricing-v2')
 
  return showNewPricing ? <NewPricingTable /> : <OldPricingTable />
}
功能用途
A/B 测试对比不同 UI/定价方案
渐进发布按比例/属性开放新功能
Kill Switch快速关闭出问题的功能

4.3 留存分析

留存是 SaaS 最重要的指标——没有留存,获客再多也是竹篮打水。

留存类型定义PostHog 设置
N-day 留存第 N 天是否回访Retention → N-day
Unbounded 留存第 N 天及之后是否回访Retention → Unbounded
功能留存某功能的重复使用率以功能事件为 returning event
// 用代码检查用户的激活状态(补充服务端逻辑)
export async function checkUserActivation(userId: string) {
  const [result] = await db.select({
    projectCount: sql<number>`count(${projects.id})`,
    firstProjectAt: sql<Date>`min(${projects.createdAt})`,
  })
    .from(projects)
    .where(eq(projects.createdBy, userId))
 
  const isActivated = result.projectCount > 0
  const ttv = result.firstProjectAt
    ? result.firstProjectAt.getTime() - (await getUserCreatedAt(userId)).getTime()
    : null
 
  return {
    activated: isActivated,
    projectCount: result.projectCount,
    timeToValue: ttv ? Math.round(ttv / 1000 / 60) : null,  // 分钟
  }
}

4.4 Session Replay

Session Replay 录制用户操作,帮助你理解"为什么"——用户在哪里卡住、放弃、迷路。

// PostHog Session Replay 配置
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
  api_host: '/ingest',
  session_recording: {
    maskAllInputs: true,           // 隐藏所有输入内容
    maskTextSelector: '.sensitive', // 隐藏标记为敏感的文本
    recordCrossOriginIframes: false,
  },
})
配置说明推荐
maskAllInputs隐藏表单输入值✅ 必须(隐私)
maskTextSelector隐藏指定元素文本标记敏感区域
blockSelector完全不录制指定元素支付表单区域
recordCrossOriginIframes是否录制跨域 iframe关闭

4.5 自定义 Dashboard

// PostHog API 查询示例:获取过去 7 天的注册趋势
export async function getSignupTrend() {
  const response = await fetch(`${POSTHOG_HOST}/api/projects/${POSTHOG_PROJECT_ID}/insights/trend/`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.POSTHOG_PERSONAL_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      events: [{ id: 'signup_completed', type: 'events' }],
      date_from: '-7d',
      interval: 'day',
    }),
  })
 
  return response.json()
}

5. Vercel Analytics 集成

5.1 Web Vitals 监控

// app/layout.tsx
import { Analytics } from '@vercel/analytics/react'
import { SpeedInsights } from '@vercel/speed-insights/next'
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
        <SpeedInsights />
      </body>
    </html>
  )
}

5.2 自定义 Web Vitals 上报

// app/vitals.ts
export function reportWebVitals(metric: {
  id: string; name: string; value: number; label: 'web-vital' | 'custom'
}) {
  // 同时上报到 PostHog
  if (metric.label === 'web-vital') {
    posthog.capture('web_vital', {
      metric_name: metric.name,
      metric_value: metric.value,
      metric_id: metric.id,
    })
  }
}
指标含义目标
LCP最大内容绘制< 2.5s
INP交互到下一次绘制< 200ms
CLS累积布局偏移< 0.1
FCP首次内容绘制< 1.8s
TTFB首字节时间< 800ms

6. 隐私合规

6.1 GDPR/CCPA 要求

要求GDPRCCPA实现
知情同意✅ 必须✅ 必须Cookie Banner
数据访问✅ 必须✅ 必须导出 API
数据删除✅ 必须✅ 必须删除 API
退出追踪✅ 必须✅ 必须Opt-out 机制
// 根据用户同意状态初始化 PostHog
export function initPostHogWithConsent(consentGiven: boolean) {
  posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
    api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
    persistence: consentGiven ? 'localStorage+cookie' : 'memory',
    autocapture: consentGiven,
  })
 
  if (!consentGiven) posthog.opt_out_capturing()
}
 
// 用户点击"接受"后
export function acceptCookies() {
  posthog.opt_in_capturing()
}
// components/cookie-banner.tsx
'use client'
 
import { useState, useEffect } from 'react'
 
export function CookieBanner() {
  const [showBanner, setShowBanner] = useState(false)
 
  useEffect(() => {
    const consent = localStorage.getItem('cookie-consent')
    if (!consent) setShowBanner(true)
  }, [])
 
  function handleAccept() {
    localStorage.setItem('cookie-consent', 'accepted')
    setShowBanner(false)
    acceptCookies()  // PostHog opt-in
  }
 
  function handleDecline() {
    localStorage.setItem('cookie-consent', 'declined')
    setShowBanner(false)
    // PostHog 保持 opt-out 状态
  }
 
  if (!showBanner) return null
 
  return (
    <div className="fixed bottom-0 inset-x-0 bg-white border-t p-4 flex items-center justify-between z-50">
      <p className="text-sm text-gray-600">
        We use cookies to improve your experience and analyze usage.
      </p>
      <div className="flex gap-2">
        <button onClick={handleDecline} className="px-4 py-2 text-sm border rounded">
          Decline
        </button>
        <button onClick={handleAccept} className="px-4 py-2 text-sm bg-black text-white rounded">
          Accept
        </button>
      </div>
    </div>
  )
}

6.4 数据删除

// 用户请求删除数据时——GDPR "被遗忘权"
export async function deleteUserData(userId: string) {
  // 1. 删除 PostHog 中的用户数据
  await fetch(`${POSTHOG_HOST}/api/person/${userId}`, {
    method: 'DELETE',
    headers: { 'Authorization': `Bearer ${process.env.POSTHOG_PERSONAL_API_KEY}` },
  })
 
  // 2. 删除数据库中的用户数据
  await db.transaction(async (tx) => {
    // 先删关联数据
    await tx.delete(memberships).where(eq(memberships.userId, userId))
    await tx.delete(sessions).where(eq(sessions.userId, userId))
    // 再删用户
    await tx.delete(users).where(eq(users.id, userId))
  })
 
  // 3. 删除 Stripe Customer
  const stripeCustomerId = await getStripeCustomerId(userId)
  if (stripeCustomerId) {
    await stripe.customers.del(stripeCustomerId)
  }
 
  // 4. 删除上传的文件
  await deleteUserFiles(userId)
}

6.5 数据访问权请求

// GDPR 数据导出——用户有权获取自己的所有数据
export async function exportUserData(userId: string) {
  const [user] = await db.select().from(users).where(eq(users.id, userId))
  const userMemberships = await db.select().from(memberships).where(eq(memberships.userId, userId))
  const userProjects = await db.select().from(projects).where(eq(projects.createdBy, userId))
 
  return {
    user: { id: user.id, email: user.email, name: user.name, createdAt: user.createdAt },
    memberships: userMemberships,
    projects: userProjects,
    exportedAt: new Date().toISOString(),
  }
}

7. 埋点测试与调试

7.1 开发环境调试

工具用途
PostHog Toolbar页面上叠加显示事件触发情况
浏览器 Network过滤 /ingest 请求检查上报
PostHog Live Events实时查看进入的事件流

7.2 埋点验证 Checklist

// 开发环境下打印所有事件到控制台
if (process.env.NODE_ENV === 'development') {
  posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
    api_host: '/ingest',
    loaded: (ph) => {
      // 拦截所有 capture 调用,打印到控制台
      const original = ph.capture.bind(ph)
      ph.capture = (event: string, properties?: any) => {
        console.log(`[Analytics] ${event}`, properties)
        return original(event, properties)
      }
    },
  })
}

7.3 埋点覆盖率检查

检查项状态说明
注册流程signup_completed + method
引导流程onboarding_step × N 步
核心功能project_created + 变体
付费流程subscription_started + plan
错误场景payment_failed + error
功能限制feature_limit_hit + feature

本章小结

  • 方案选型:PostHog(产品分析)+ Vercel Analytics(性能)+ Sentry(错误)三件套
  • PostHog 集成:Provider + Suspense,手动 pageview,反向代理绕 Ad Blocker
  • 身份关联:identify 用户 + group 组织,Super Properties 全局属性
  • 类型安全AnalyticsEvents 类型约束事件名和属性,编译期检查
  • 服务端埋点posthog-node 在 Server Action/Webhook 中追踪支付等关键事件
  • React HookuseTrack() 封装,TrackedForm 自动追踪表单提交时长
  • 漏斗分析:访问 → 注册 → 激活 → 留存 → 付费,每阶段有明确优化目标
  • 留存分析:N-day / Unbounded / 功能留存,TTV 激活时间检测
  • Session Replay:录制用户操作,maskAllInputs 保护隐私
  • Vercel Analytics:Web Vitals 监控,LCP < 2.5s / INP < 200ms / CLS < 0.1
  • 隐私合规:Cookie Banner + opt-out + GDPR 数据删除 + 数据导出 API
  • 调试:开发环境 console.log 拦截,埋点覆盖率 Checklist