数据埋点与用户分析
"没有数据就没有真相"——SaaS 产品的每一个决策都应该有数据支撑。埋点系统回答三个核心问题:用户从哪来(获客)、怎么用(激活/留存)、为什么付费(转化)。本章讲解 Next.js SaaS 中的分析体系。
1. 分析方案选型
1.1 方案对比
| 方案 | 类型 | 隐私 | 价格 | 推荐场景 |
|---|
| PostHog | 全功能(事件+回放+Feature Flag) | 可自托管 | 免费层慷慨 | ✅ SaaS 首选 |
| Vercel Analytics | Web 性能分析 | 符合 GDPR | 随 Vercel | 性能监控 |
| Plausible | 轻量网站分析 | 隐私优先 | $9/月 | 营销站 |
| Mixpanel | 事件分析 | 美国托管 | 免费层 | 产品分析 |
| Google Analytics | 全功能 | 隐私争议 | 免费 | ⚠️ GDPR 风险 |
1.2 推荐组合
| 层级 | 工具 | 用途 |
|---|
| 产品分析 | PostHog | 事件、漏斗、留存、Session Replay |
| 性能监控 | Vercel Analytics | Web 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 Retention | 7 天后仍活跃的用户占比 | 20-30% |
| 留存 | D30 Retention | 30 天留存率 | 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 事件命名规范
| 类别 | 命名格式 | 示例 |
|---|
| 页面浏览 | $pageview | PostHog 内置 |
| 功能使用 | feature_used | project_created |
| 转化事件 | conversion_xxx | subscription_started |
| 错误事件 | error_xxx | error_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%
| 阶段 | 对应事件 | 优化目标 |
|---|
| 访问 → 注册 | $pageview → signup_completed | 提升注册转化率 |
| 注册 → 激活 | signup_completed → onboarding_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 要求
| 要求 | GDPR | CCPA | 实现 |
|---|
| 知情同意 | ✅ 必须 | ✅ 必须 | Cookie Banner |
| 数据访问 | ✅ 必须 | ✅ 必须 | 导出 API |
| 数据删除 | ✅ 必须 | ✅ 必须 | 删除 API |
| 退出追踪 | ✅ 必须 | ✅ 必须 | Opt-out 机制 |
6.2 Cookie Consent
// 根据用户同意状态初始化 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()
}
6.3 Cookie Banner 组件
// 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 Hook:
useTrack() 封装,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