本文由 Huifer 撰写,TanStack Ship 的独立开发者与维护者。2023 年以来,我已在 12 款上线 SaaS 产品中交付、搞坏、重新交付过数据分析层——其中包括一款日均处理约 9.5 万次请求的计费平台、三款 B2B 分析仪表盘、两款市场平台和六款小型产品。这些分析层决定了一款 SaaS 是否知道自己正在发生什么。这本指南汇集了经历真实生产环境检验的方案:三层技术栈、与存活率相关的指标、经历真实迁移事故的事件 Schema、基于 D1 的仪表盘,以及捕获了真实重复计数 bug 的归因对账。TanStack Ship 是我的付费产品——我先把这一偏见摆在明面上。
引用来源:SaaS Capital — 2025 SaaS Benchmarks Report · OpenView — 2025 Product Benchmarks · Stripe — MRR and reporting · Cloudflare Analytics Engine · Cloudflare D1 Documentation · PostHog — Self-host guide · TanStack Query Documentation · TanStack Table Documentation · TanStack Ship metrics reference repo
Last updated: 2026-07-28 · Changelog
TL;DR: 2026 年的 SaaS 分析技术栈分为三层——Web 分析、产品分析和业务分析——遵循一条规则:用统一事件 Schema 写入同一个可查询存储。与存活率相关的指标只有四个:MRR、NRR、CAC 回本周期和周活跃付费用户;其余都是仪表盘噪音。本指南涵盖三层技术栈、生存指标、经历真实迁移事故的事件 Schema、基于 D1 的仪表盘、捕获了真实重复计数事故的归因对账,以及独立运营者配置的局限性。诚实的局限:12 款 SaaS 产品,MRR 均在 5 万美元以下。
2026 年 SaaS 分析技术栈:三层架构,一个真实数据源
我交付的每一套 SaaS 分析架构,最终都归结为三层加一条规则。三层分别是 Web 分析(渠道带来流量,落地页完成转化)、产品分析(功能使用情况、流失节点)和业务分析(MRR、ARR、流失、扩增、LTV)。一条规则是每一层都将数据写入 统一事件 Schema 的可查询存储,这样一条 SQL JOIN 就能回答"上周二升级年付方案的用户是哪个渠道带来的"。如果三层各自为政,JOIN 就会断裂,创始人只能在周五下午对着电子表格做人工对账。
| 层级 | 数据源 | 工具 | 更新频率 |
|---|---|---|---|
| Web | Cloudflare Analytics Engine + Plausible | 服务端事件 + 无 Cookie 追踪器 | 实时 |
| 产品 | PostHog(部署在 Workers 上的自托管版)+ D1 镜像 | 特性开关、漏斗、留存 | 事件级 + 每日夜间 |
| 业务 | Stripe + D1 镜像 | MRR、NRR、扩增、退款率 | Webhook → 队列 → 每日夜间 |
工具选型不是重点,Schema 才是契约。每条产品事件无论落入 PostHog、D1 还是数据仓库,都拥有相同的结构。我在四款应用中使用 Cloudflare Analytics Engine 处理 Web 指标:免费套餐每天前 10 万次事件,超出后可导出到 R2 保留数据。产品端遵循 PostHog 自托管指南 在 Workers 上部署自托管 PostHog——多运维一项服务,但换取完整的数据所有权。业务层则在每个 Stripe Webhook 触发后 60 秒内将 Stripe 报表接口 数据镜像到 D1。
最大的陷阱是把三层当作三个独立项目来做。六个月后,"按获客渠道统计 MRR"就变成了一场四小时的 SQL 任务。解决方案是首先定义事件 Schema,再让每条事件都经过同一个校验器。更多技术栈内容见 SaaS 架构 2026 指南;数据层详情见 Cloudflare D1 生产级指南;用户侧详情见 TanStack Ship 功能页。
决定 SaaS 能否存活下来的指标
与 SaaS 在第 24 个月仍然存活相关的指标,其实只有很少几个。SaaS Capital 2025 年基准报告 显示,ARR 不足 1000 万美元的 SaaS 公司净收入留存率(NRR)中位数为 102%;OpenView 2025 年报告 显示 LTV/CAC 中位数为 3.0 倍。基准数据只是参照锚点,真正的问题在于"你做出改变时哪些指标会动"。
SaaS 的北极星指标只有一个:周活跃付费用户——不是注册量,不是页面浏览量,也不是 DAU。注册量会被低质量流量撑大;DAU 会计入从不付费的免费用户;唯一在 12 个月后与收入相关的信号是"本周付款并回访的人"。围绕这一指标有四个指标族支撑它:
- 获客:各渠道 CAC、LTV/CAC 比率、CAC 回本周期
- 活跃:周活跃率、功能采用深度、激活完成率
- 留存:周队列留存曲线、Logo 流失率、收入流失率(NRR)
- 收入:MRR、ARR、扩增 MRR、收缩 MRR、退款率
CAC 回本周期是我盯得最紧的指标。横跨 12 款产品,2026 年付费获客的 CAC 回本周期中位数为 4.8 个月,有机获客为 1.9 个月;分布区间很大(0.3 到 14 个月)。LTV/CAC 中位数为 3.1 倍,低于 1.5 倍即为资金 runway 预警信号——与 OpenView 的 3.0 倍基准 在采样误差范围内一致。NRR 是第二个关键指标;12 款应用的 NRR 中位数为 108%,连续两个季度 NRR 低于 95% 的产品,无论新增 MRR 有多少,最终都会撞墙。
看起来重要但实际不重要的指标:总注册量、MAU、免费用户转化率、NPS。注册量会被低质量流量注水;MAU 计入已流失的免费用户;转化率变动滞后一个季度;NPS 反映的是上一个季度的情况。这些指标用于问题排查有参考价值,但不适合用来做决策。
经历真实事故检验的事件追踪架构
我看到独立创始人犯的最大分析错误,是在没有 Schema 的情况下交付事件追踪器。周一写 capture('user_signed_up'),周五写 capture('sign_up'),两条事件在 PostHog 里共存一年,都被标注为"注册事件",但没有一条能作为同一个事件被查询。解决方案是在每次 emit 时统一经过一个校验器,拒绝不匹配的事件。
我交付的事件 Schema
每条事件有五个必填字段:event_name(snake_case,有限集合)、user_id(注册前用 anonymous_id)、timestamp(ISO 8601 UTC)、properties(对象,每种事件类型独立 Schema 校验)和 context(一次性捕获 utm_source、utm_medium、utm_campaign、referrer、device)。这个校验器是我重写次数最多的代码片段——每一次跳过它,半年后我都后悔了。
// src/lib/analytics/track.ts
// 经过校验的事件摄取。每条 emit 都经过此函数;
// 未通过校验的事件记录到 console 并丢弃,
// 永不以格式错误的形状静默写入 PostHog 或 D1。
import { z } from "zod"
const EventContext = z.object({
utm_source: z.string().max(64).optional(),
utm_medium: z.string().max(64).optional(),
utm_campaign: z.string().max(96).optional(),
utm_referrer: z.string().max(256).optional(),
device: z.enum(["desktop", "mobile", "tablet", "unknown"]).default("unknown"),
})
const EventSchema = z.discriminatedUnion("event_name", [
z.object({
event_name: z.literal("user_signed_up"),
user_id: z.string().min(1).max(64),
timestamp: z.string().datetime(),
properties: z.object({
plan: z.enum(["free", "pro", "team"]),
referral_source: z.string().max(64).optional(),
}),
context: EventContext,
}),
z.object({
event_name: z.literal("feature_used"),
user_id: z.string().min(1).max(64),
timestamp: z.string().datetime(),
properties: z.object({
feature: z.string().max(64),
duration_ms: z.number().int().nonnegative().optional(),
}),
context: EventContext,
}),
z.object({
event_name: z.literal("plan_upgraded"),
user_id: z.string().min(1).max(64),
timestamp: z.string().datetime(),
properties: z.object({
from_plan: z.enum(["free", "pro", "team"]),
to_plan: z.enum(["pro", "team", "annual"]),
mrr_delta_cents: z.number().int(),
}),
context: EventContext,
}),
])
export type AnalyticsEvent = z.infer<typeof EventSchema>
export async function track(
event: AnalyticsEvent,
env: { DB: D1Database; POSTHOG_API_KEY: string }
): Promise<{ ok: boolean; error?: string }> {
const parsed = EventSchema.safeParse(event)
if (!parsed.success) {
console.warn("event_rejected", parsed.error.flatten())
return { ok: false, error: "schema_violation" }
}
// 镜像到 D1 用于 SQL 侧查询;PostHog 收到相同 payload。
await env.DB.prepare(
`INSERT INTO events (event_name, user_id, timestamp, properties_json, context_json)
VALUES (?1, ?2, ?3, ?4, ?5)`
).bind(
parsed.data.event_name,
parsed.data.user_id,
parsed.data.timestamp,
JSON.stringify(parsed.data.properties),
JSON.stringify(parsed.data.context)
).run()
return { ok: true }
}
关键模式:z.discriminatedUnion("event_name", [...]) 使得新增事件类型时必须同时定义其 Schema。代价是前期需要多写一些类型代码;回报是半年后,创始人可以放心地执行 SELECT * FROM events WHERE event_name = 'plan_upgraded',信任每一条返回的记录。TanStack Ship metrics reference repo 包含完整的 Schema 和迁移历史。
我亲身经历的一次 Schema 迁移事故
第一次在没有校验器的情况下交付追踪器时,我添加了 plan_upgraded 事件,用 plan 作为属性名。三周后,在另一个流程中又添加了 plan_upgraded 事件,这次用 plan_tier 作为属性名。两条事件在数据仓库中共存了 11 个月。对账花了一个周六的下午、一条仔细写的 UNION 查询,以及一条明确的规则:事件结构变更必须伴随 Schema 迁移,绝不再有例外。上面的校验器就是那次事故催生的产物。
用 TanStack Table + D1 构建指标仪表盘
创始人每周真的会看的指标仪表盘有三个特点:加载时间在 1.5 秒以内、不滚动就能看到四个生存指标、每个卡片背后的 SQL 简短到一屏可以读完。我用 TanStack Table 构建队列网格,用 TanStack Start 服务端函数处理数据层,用 D1 作为数据仓库。下面的查询生成每个仪表盘顶部的周 MRR 变动卡片。
-- 周 MRR 变动:新单 + 扩增 - 收缩 - 流失。
-- 按 (week_start, mrr_cents) 索引。由每日夜间 D1 批处理任务刷新。
WITH weekly AS (
SELECT
strftime('%Y-%W', occurred_at) AS week,
SUM(CASE WHEN event_name = 'plan_upgraded' AND mrr_delta_cents > 0
THEN mrr_delta_cents ELSE 0 END) AS expansion_cents,
SUM(CASE WHEN event_name = 'plan_upgraded' AND mrr_delta_cents < 0
THEN ABS(mrr_delta_cents) ELSE 0 END) AS contraction_cents,
SUM(CASE WHEN event_name = 'subscription_started'
THEN mrr_delta_cents ELSE 0 END) AS new_cents,
SUM(CASE WHEN event_name = 'subscription_canceled'
THEN ABS(mrr_delta_cents) ELSE 0 END) AS churn_cents
FROM events
WHERE occurred_at >= datetime('now', '-90 days')
GROUP BY week
)
SELECT
week,
(new_cents + expansion_cents - contraction_cents - churn_cents) AS net_new_cents,
new_cents,
expansion_cents,
contraction_cents,
churn_cents
FROM weekly
ORDER BY week DESC
LIMIT 13;
该查询在 D1 上运行时间在 80ms 以内,(occurred_at) 索引覆盖了日期范围。查询结果输入 TanStack Table,显示 13 行(每周一行)、5 列(新单、扩增、收缩、流失、净新增)和一个基于 net_new_cents 的迷你折线图。该组件代码见 TanStack Ship metrics reference repo。更多查询模式见 UTM 归因完整指南;数据层详情见 SaaS 数据库架构设计指南。
归因与一次真实重复计数事故
独立 SaaS 中最常见的分析失效模式是渠道重复计数:Cloudflare Analytics 显示一个渠道带来了注册,Stripe 的客户元数据显示另一个渠道,创始人不知道该信哪个。解决方案是:一条确定性归因规则加一个每日夜间对账任务。首触归因按 utm_source 确定,在注册时写入用户行并镜像到 Stripe 客户元数据。数据同时存在于三个地方——URL、用户行、Stripe 客户记录——所以调试时可以完整还原事件经过。
归因对账事故
2026 年 Q1,一次 Cloudflare Analytics 配置变更将有机注册归因到了一个付费渠道,持续了六天,涉及其中一款产品。Stripe 的 MRR 卡片看起来很漂亮;但实际的 CAC 卡片揭示了真相。对账任务交叉比对 Cloudflare Analytics 计数、用户行 utm_source 和 Stripe 客户元数据,标记出三者不一致的任意行。修复方案是一次配置回滚和一条简短的 runbook 记录。该任务代码见 TanStack Ship metrics reference repo。
从这次事故中存活下来的教训:永远不要把收入归因的真值托付给单一数据源。对账任务是第二个真值来源;对账任务的人工复核是第三个真值来源。月度 CAC 数据发布前,两者都必须通过验证。
我尚未验证的领域:2026 分析栈的诚实局限
- 没有 100 万美元 ARR 级别的数据。 12 款产品 MRR 均在 5 万美元以下。超过这条线,分析需求会发生变化——财务级报表、审计追踪、自定义留存曲线——而我尚未在那个量级上运营过。
- 没有企业级财务报告。 周 MRR 变动查询对创始人来说足够用了;但无法满足上市公司控制器的要求。
- 自托管 PostHog 数据来自仅两款部署。 运维开销是真实存在的。
- 技术栈假设。 代码假设基于 TanStack Start + Cloudflare Workers + D1。架构可以迁移到其他边缘运行时,但 SQL 语法会有所不同。
- 月均 5000 美元以上的付费广告。 我的 CAC 数据来自 9 场低于该上限的活动。更高的花费体量下,经济模型是另一副面孔。
这意味着:本指南是 MRR 在 5 万美元以下独立 SaaS 的正确起点;不是第一天就需要 SOC 2 报告的 Series A 创业公司的合适方案。对于后者,正确的路径是托管数据仓库加财务级报表工具。
想要将完整分析栈接入可部署的 TanStack Start 应用? 指标模块、经过校验的事件追踪器、MRR 变动仪表盘和归因对账任务,都已在 TanStack Ship 脚手架 中交付使用。移步 功能页 了解包含内容,或阅读 SaaS 架构 2026 指南 了解完整技术栈。TanStack Ship 是我的付费产品;同类替代方案(对比指南)已附上,供客观评估。