Files
lixi 1891d9b7b4
CI / docs-build (push) Successful in 1m3s
docs: M2 开工前分析 10 份报告入档 + ADR-009~015 拍板决策
- iteration-2 报告 01-08(PM 拆解/后端/Flutter 评估/现实核查/UI 规范/埋点规划/证据基线/Git 规划),04、06 已由正式角色复核定稿
- mkdocs 挂「第二迭代」导航,build --strict 通过
- ADR-009 新建 patbond-pet 模块、ADR-010 照片剪出 M2、ADR-011 dev 主干/master 发布、ADR-012 北极星与 H1-H4、ADR-013 废弃 health_record_action、ADR-014 DEBT-1 随 M2、ADR-015 照护人邀请后置

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 13:54:35 +08:00

525 lines
41 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 第二迭代埋点与实验规划(宠物健康档案)
> 角色:Experiment Tracker(本版为角色复核定稿;初版由通用 agent 代拟,已整体接管)
> 日期:2026-09-07
> 前序:iteration-1 `05-experiment-tracking-plan.md`(事件与指标规划)、`13-tracking-implementation-spec.md`(工程规范与字典 v1)、`19-analytics-implementation-report.md`M0 简化版落地实况)
> 依据:`development-plan.md` 第 7 节 M2、第 9 节「可观测性与产品验证」;`patbond-api` `EventDictionary.java` 现行白名单;`patbond-flutter` `lib/analytics/analytics_service.dart` 现状;本迭代 `01-pm-task-breakdown.md`M2 范围与验收)
> 范围:M2 健康档案纵切(宠物、体重、疫苗、健康事件、提醒);社区、AI 创作、本地服务不在本轮定义
> 性质:纯规划文档,供 M2 开发工单直接引用;不含任何代码改动
**本版相对初版的复核结论(速览)**
1. 初版的事件字典 v2 增量(10 事件)、护栏指标、对账 SQL、基础设施评估经复核**基本成立,予以保留**;漏斗闭环复核见 §1.6,发现并修订一处实质缺口(pageName 枚举缺 `pet_form`)。
2. 北极星初版只给了方向没给可操作口径——本版**落定候选 A「7 日回访记录率」的完整定义式**(分母、去重、窗口边界、成熟期、SQL),见 §2.1。
3. **新增 4 条可证伪产品假设 H1–H4**(初版完全缺失),每条带判定指标、阈值、数据源、观察窗口与证伪后行动,见 §3——这是实验规划区别于纯埋点规划的核心。
4. 「M2 不启动 A/B」的判断成立,但初版只说「前置未绿」不给路线——本版给出 8 项前置条件 × 预计达成迭代,结论:**M3 末可全绿,M4 启动首个实验**,见 §4。
5. 客户端三处偏差声称**已由本角色重新实读代码逐一实锤**(§0);废弃 `health_record_action` 的立场:**同意直接移除**,并补充实验视角理由(§1.2)。
---
## 0. 基线现状(开工前核对)
| 项 | 现状 | 出处 |
| --- | --- | --- |
| 后端接收端 | `POST /api/v1/events` 已上线:批量 1–50 条、202 逐条结果、eventId 幂等、白名单剥离、红线拒绝、匿名可报 | 报告 19 §1.1 |
| 后端字典 | v1 的 11 个 `auth_*` 事件 + 工单增补 `page_viewed(pageName, referrer)``health_record_action(recordType, actionType)` | `EventDictionary.java` |
| 存储 | `platform.product_events`Flyway V2v1 不分区,触发分区阈值约 5,000 万行) | 报告 13 §2 |
| Flutter 采集 | `AnalyticsService` 已挂 3/5 挂接点(登录/注册/退出);`page_viewed``health_record_action` 仅 TODO 注释 | 报告 19 §1.2 |
| Flutter 队列 | shared_preferences 持久化,上限 500 条 | `analytics_service.dart` |
### 0.1 客户端三处偏差(本角色实读 `analytics_service.dart` 复核,全部实锤)
| # | 偏差 | 证据(行号) | 对实验数据的影响 |
| --- | --- | --- | --- |
| 1 | `sessionId` 每事件独立生成 | 第 55 行 `'sessionId': const Uuid().v4(), // Simplified: unique per event (M0)` | 会话维度整体不可用:§6.3 巡检、护栏 5 的代偿口径、page_viewed 覆盖率 sanity 全部依赖它 |
| 2 | `eventId` 为 UUID v4 而非规范要求的 v7 | 第 50 行 `'eventId': const Uuid().v4()` | 去重不受影响;随机主键丧失插入时间局部性,量级上来后 B-tree 写放大 |
| 3 | `appVersion`/`osVersion` 硬编码 | 第 57 行 `'1.0.0+1'`;第 5961 行 `'android-14'`/`'ios-17'`(均留 TODO) | 版本维度全体失真,M2 起按版本切片看回归不可行 |
结论:接收链路可信、可直接承载 M2 新事件;三处偏差**须在 M2 第一波修复**(§5、§7.2),否则本迭代新指标的会话与版本维度都是坏数据。§3 的假设判定与 §2.1 北极星均已刻意设计为**不依赖 sessionId**(只用 userId + server_ts),即便修复延迟,核心读数不受污染——但漏斗 sanity 与护栏会瞎。
---
## 1. 事件字典 v2 增量(health_record 域)
### 1.1 沿用 v1 的设计原则(不复述,仅列约束)
命名 `<域>_<动作>_<结果>` snake_case`eventVersion` 起始 1、变更递增禁止原地改语义;客户端采集、`serverTs` 服务端补写为统计权威时间;`eventId` UUIDv7 幂等;属性 camelCase;公共属性(报告 13 §4.0 十项)全体必带。M2 新增两个域前缀:**`pet`**(宠物实体)与 **`health_record`**(档案记录)。
### 1.2 `health_record_action` 保留位的处置:废弃并直接移除(本角色立场:同意)
M0 工单在档案功能设计之前,往后端字典预置了通用事件 `health_record_action(recordType, actionType)`。v2 决定**不启用该保留位,以细分事件取代**:
1. v1 惯例把结果编码进事件名(`_succeeded`/`_failed`),使每个事件有独立 props 白名单与独立失败枚举;`actionType` 把 4 种动作塞进一个事件,白名单只能取并集,失败语义无处安放。
2. 漏斗指标(§2.2)需要 `started → succeeded` 配对事件,通用事件表达不了。
3. **实验视角补充理由(本角色)**:假设验证要求「一个指标定义式只引用语义单一的事件」。若 H1(记录类型分布)与漏斗完成率共用一个 `health_record_action`,则任何一次 `actionType` 枚举扩充都会同时污染两套指标口径的分母——细分事件把这种耦合从源头切断。§3 全部 4 条假设都以细分事件为数据源,保留位对假设验证零贡献。
4. **废弃是零成本的**:本角色 grep 全库核实,`patbond-flutter/lib` 下对该事件名 **0 处引用**(仅后端白名单一行 + 注释),不存在兼容负担。
处置:后端工单从 `EventDictionary` 白名单**直接移除**该条目(连同 `actionType``recordType` 作为属性名由 §1.4 各细分事件继承);Flutter 侧 TODO 注释指向的挂接位置改挂 §1.4 细分事件。
同场收编:`page_viewed(pageName, referrer)` 同为工单增补、未进字典正稿,v2 将其**转正**(定义见 §5.2,pageName 必须是枚举,禁止自由路由字符串)。
### 1.3 隐私红线增量(在 v1 六条红线之上追加,针对档案内容)
埋点只记录**行为**,不记录**内容**——内容分析一律走服务端事实表(M2 验收「体重、疫苗进度……从事实表聚合」本来就要求事实表可查)。任何事件禁止携带:
1. **宠物名、品种自由文本**:物种用 `species` 枚举(`cat`/`dog`/`other`),品种不上报。
2. **档案自由文本**:备注、症状描述、提醒文案原文。
3. **精确数值**:体重公斤数、花费金额、疫苗批号。
4. **媒体线索**:照片 URL、文件名、本地路径(只允许 `photoCount` 整数)。
5. **路由参数**`page_viewed.pageName``referrer` 必须是归一化枚举——`/pet/3f8a…` 一律归一为 `pet_detail`,禁止把宠物/记录 UUID 混进页面名。
红线正则(`password|token|secret|phone|mobile|email|credential|idfa|gaid`**本轮不扩**:加 `name`/`note` 类宽泛词会误伤 `pageName``recordType` 等合法字段;内容字段靠白名单剥离兜底,另新增值级巡检(§6.4)补防线。
### 1.4 新事件清单
`recordType` 枚举(多事件共用,对应 M2 四类记录接口):`weight` / `vaccine` / `health_event` / `reminder`
失败枚举基底(在 v1 的 `validation_error`/`rate_limited`/`network_error`/`server_error` 之上,按 M2 验收新增):
- `permission_denied` — 无权限访问宠物(403owner/caregiver/viewer 权限模型的观测点)
- `conflict` — 并发更新冲突(M2 验收「并发更新返回明确冲突」的观测点)
- `not_found` — 目标宠物/记录已被删除(多设备场景)
#### 宠物创建(pet 域)
| 事件名 | 触发时机 | 专有属性 |
| --- | --- | --- |
| `pet_create_started` | 用户进入建宠表单并产生**首次输入**(到达表单页由 `page_viewed(pageName=pet_form)` 承接,见 §1.6 修订),每次进入记一次 | `entryPoint``profile_empty_state` / `pet_list` / `post_register_guide`,枚举待 UI 定稿收敛) |
| `pet_create_succeeded` | 客户端收到建宠接口成功响应(code=0)后(**漏斗事件**) | `durationMs``species`(枚举)、`petIndex`(该用户第几只宠物,int,H2 假设的直接数据源) |
| `pet_create_failed` | 失败响应 / 超时 / 本地校验拦截 | `failureReason``errorCode`(可空)、`httpStatus`(可空)、`attemptSeq` |
`pet_create_failed.failureReason``validation_error``pet_limit_reached`(若产品设上限,**待拍板**:无上限则删此枚举)、`rate_limited``network_error``server_error`
> 说明:示例名 `pet_created` 不符合 v1「结果后缀」惯例,按 `<域>_<动作>_<结果>` 正名为 `pet_create_succeeded` 系列。
#### 健康记录创建(health_record 域)
| 事件名 | 触发时机 | 专有属性 |
| --- | --- | --- |
| `health_record_create_started` | 进入某类记录的创建表单并产生首次输入 | `recordType``entryPoint``pet_detail` / `record_list` / `reminder`,待 UI 定稿收敛) |
| `health_record_create_succeeded` | 收到创建接口成功响应后(**漏斗事件**,北极星与 H1/H3/H4 的核心数据源) | `recordType``durationMs``photoCount`int,无照片为 0 |
| `health_record_create_failed` | 失败响应 / 超时 / 本地校验拦截 | `recordType``failureReason``errorCode``httpStatus``attemptSeq` |
`failureReason``validation_error``permission_denied``not_found``rate_limited``network_error``server_error`
#### 记录浏览 / 编辑 / 删除
| 事件名 | 触发时机 | 专有属性 |
| --- | --- | --- |
| `health_record_viewed` | 记录**详情**页可见(列表滚动曝光不算,防事件洪水) | `recordType``source``record_list` / `pet_detail` / `reminder` |
| `health_record_edit_succeeded` | 编辑保存成功响应后 | `recordType``fieldCount`(本次变更字段数,int,可空) |
| `health_record_edit_failed` | 编辑保存失败 | `recordType``failureReason`(含 **`conflict`**)、`errorCode``httpStatus` |
| `health_record_deleted` | 删除成功响应后(仿 `auth_logout` 单事件风格;删除失败不埋,靠服务端接口错误率观测) | `recordType` |
宠物列表/详情的**浏览**不设 `pet_viewed`——由 `page_viewed``pageName = pet_list` / `pet_detail`)覆盖,避免双事件重复计数。编辑不设 `started`:短表单,started→succeeded 漏斗价值低于事件成本;若编辑放弃率成为问题再以 eventVersion=2 增补。
### 1.5 v2 增量总览(10 个新事件 + 1 转正 + 1 废弃)
| # | 事件名 | 版本 | 性质 |
| --- | --- | --- | --- |
| 12 | `pet_create_started` | 1 | 新增 |
| 13 | `pet_create_succeeded` | 1 | 新增(漏斗事件) |
| 14 | `pet_create_failed` | 1 | 新增 |
| 15 | `health_record_create_started` | 1 | 新增 |
| 16 | `health_record_create_succeeded` | 1 | 新增(漏斗事件) |
| 17 | `health_record_create_failed` | 1 | 新增 |
| 18 | `health_record_viewed` | 1 | 新增 |
| 19 | `health_record_edit_succeeded` | 1 | 新增 |
| 20 | `health_record_edit_failed` | 1 | 新增 |
| 21 | `health_record_deleted` | 1 | 新增 |
| — | `page_viewed` | 1 | 转正(工单增补 → 字典正稿,pageName 枚举化) |
| — | `health_record_action` | — | **废弃**(从未启用,后端白名单直接移除,见 §1.2) |
后端 `EventDictionary` 白名单增量(工单可直接抄):
```java
Map.entry("pet_create_started", Set.of("entryPoint")),
Map.entry("pet_create_succeeded", Set.of("durationMs", "species", "petIndex")),
Map.entry("pet_create_failed",
Set.of("failureReason", "errorCode", "httpStatus", "attemptSeq")),
Map.entry("health_record_create_started", Set.of("recordType", "entryPoint")),
Map.entry("health_record_create_succeeded", Set.of("recordType", "durationMs", "photoCount")),
Map.entry("health_record_create_failed",
Set.of("recordType", "failureReason", "errorCode", "httpStatus", "attemptSeq")),
Map.entry("health_record_viewed", Set.of("recordType", "source")),
Map.entry("health_record_edit_succeeded", Set.of("recordType", "fieldCount")),
Map.entry("health_record_edit_failed",
Set.of("recordType", "failureReason", "errorCode", "httpStatus")),
Map.entry("health_record_deleted", Set.of("recordType"))
// 同时删除 Map.entry("health_record_action", ...) —— 从未启用,见 §1.2
```
Flutter 侧沿用报告 13 §3.1 的强类型封装惯例:新建 `pet_analytics.dart` / `health_record_analytics.dart`,枚举编译期锁死,业务代码禁止手拼事件名与属性。
### 1.6 漏斗闭环与维度够用性复核(本角色新增)
复核方法:以 §3 的 4 条假设 + §2 全部指标逐条反推数据源,凡定义式引用了字典中不存在的事件/属性即判缺口。结论如下。
**闭环成立**`pet_create``health_record_create` 两条漏斗均有 started → succeeded / failed 配对,失败枚举覆盖 M2 验收要求的权限(`permission_denied`)与并发(`conflict`)场景,闭环判定通过。编辑不设 started、删除不埋失败,属自觉取舍,同意(复活条件已在 §1.4 注明)。
**修订 1(实质缺口,本版已修)**:初版 pageName 枚举为 `login / register / home / profile / pet_list / pet_detail / record_form / record_detail`**缺建宠表单页**。`pet_create_started` 定义在「首次输入」触发,意味着「到达表单即放弃」的人群只能靠 page_viewed 兜住——枚举里没有建宠表单页名,建宠漏斗的「到达 → 动笔」段就不可测,完成率分母系统性偏小、读数虚高。**修订:pageName 枚举增补 `pet_form`**,建宠漏斗三段式为 `page_viewed(pet_form) → pet_create_started → pet_create_succeeded`;健康记录漏斗同理由 `record_form` 承接到达段(初版已有此页名,无需改)。
**缺口 2(接受不埋)**:宠物编辑/删除无事件——低频管理动作,不构成漏斗,服务端事实表可查,不埋。
**缺口 3(接受不埋,有条件)**:提醒完成/忽略(pending→completed/dismissed)无事件。H3 的验证只需「提醒创建」(`recordType=reminder`)与回访事件,均已具备;提醒完成率从 `care_reminders` 事实表(`status`/`completed_at`)出数即可。**条件**:若 M3+ 要做提醒推送类实验,届时必须增补 `reminder_completed` 事件(记入字典 backlog),因为推送实验的主指标需要客户端行为时序而非仅终态。
**维度够用性**H1 需 `recordType`(有);H2 需 `petIndex`(有,另以 `pet.pets` 事实表交叉验证);H3 需 `recordType=reminder` 分群 + userId 时序(有);H4 需 `pet_create_succeeded``health_record_create_succeeded` 的 userId + serverTs(有)。**全部假设可由本字典 + M2 事实表回答,维度判定通过。**
---
## 2. M2 指标体系(北极星定义式落定 + 漏斗 + 护栏)
统计口径沿用 v1`serverTs` 划 UTC 日界;主体去重用 `userId`M2 事件全部发生在登录后,`anonymousId` 兜底理论上不该出现——出现即数据质量信号,§6.5 巡检)。
### 2.1 北极星:7 日回访记录率(本角色裁定采用候选 A,定义式落定)
初版将 A/B 二选一列为待拍板且只给了方向性描述。本角色以实验专业裁定:**采用 A「7 日回访记录率」为北极星,B「档案激活率」降级为辅助漏斗指标**(保留 PM 否决权,见 §8)。理由:健康档案的产品价值在「持续记录」而非「一次性录入」;A 是留存型指标,难被一次性强引导冲高,B 恰恰易被冲高从而与长期价值背离——B 适合做诊断,不适合做方向。
初版定义(「首次成功后 7 个自然日内再次 ≥1 条」)存在三处不可操作的模糊:同日批量录入算不算回访?窗口从时刻算还是从日界算?分母是哪个「首次」?本版落定如下。
**定义式**
```
7日回访记录率(w) =
| { u : firstRec(u) ∈ 周 w,且 ∃ e ∈ E(u)day(e) ∈ [day(firstRec(u))+1, day(firstRec(u))+7] } |
─────────────────────────────────────────────────────────────────────────────
| { u : firstRec(u) ∈ 周 w } |
```
口径逐项:
| 要素 | 落定口径 | 理由 |
| --- | --- | --- |
| firstRec(u) | 用户 u **平台生命周期内首条** `health_record_create_succeeded``server_ts`(min),非「本周期首条」 | 回访衡量习惯养成,只对真正的新记录用户有意义 |
| 分母 | firstRec 落在 ISO 周 wUTC)内的去重 `userId``user_id IS NULL` 的事件不计入(应为空集,§6.5 兜底) | 按首记周分队列,队列间互斥 |
| 分子 | 分母中,在 **day(firstRec)+1 至 day(firstRec)+7**(UTC 自然日,**不含首记当日**)内再产生 ≥1 条 `health_record_create_succeeded` 者;任意 `recordType`、任意宠物均算 | **排除首记当日**是关键:不排除则首次使用时同会话批量录入 3 条体重也算「回访」,指标失去留存含义 |
| 回访事件范围 | 仅创建成功事件;`viewed`/`edit` 不算回访 | 北极星衡量「持续产生记录」,浏览是弱得多的信号,混入会稀释 |
| 删除处理 | 记录事后被删不影响计数(行为已发生) | 事件表不可变语义 |
| 队列成熟期 | 队列须等到 day(firstRec)+8(UTC)才可出数;未成熟队列不发布 | 防止半熟队列读数系统性偏低 |
| 去重 | 全程 `userId`;多设备同账号合并计 | 会话/设备维度不参与——刻意使北极星**不依赖 sessionId**(偏差 1 修复与否不污染北极星) |
**出数 SQL(巡检脚本可直抄)**
```sql
WITH first_rec AS (
SELECT user_id,
date_trunc('day', min(server_ts) AT TIME ZONE 'UTC') AS first_day,
date_trunc('week', min(server_ts) AT TIME ZONE 'UTC') AS cohort_week
FROM platform.product_events
WHERE event_name = 'health_record_create_succeeded' AND user_id IS NOT NULL
GROUP BY user_id
),
returned AS (
SELECT DISTINCT f.user_id
FROM first_rec f
JOIN platform.product_events e
ON e.user_id = f.user_id
AND e.event_name = 'health_record_create_succeeded'
AND date_trunc('day', e.server_ts AT TIME ZONE 'UTC')
BETWEEN f.first_day + interval '1 day' AND f.first_day + interval '7 day'
)
SELECT f.cohort_week,
count(*) AS cohort_users,
count(r.user_id) AS returned_users,
round(100.0 * count(r.user_id) / count(*), 2) AS return_rate_pct
FROM first_rec f
LEFT JOIN returned r USING (user_id)
WHERE f.first_day + interval '8 day' <= date_trunc('day', now() AT TIME ZONE 'UTC') -- 只出成熟队列
GROUP BY f.cohort_week
ORDER BY f.cohort_week;
```
**统计纪律**:早期周队列样本小,读数按 Wilson 95% 置信区间发布(不裸报点估计);队列人数 < 50 的周与相邻周合并或改用 4 周滚动口径,禁止对小样本周环比做趋势解读。
**辅助指标 B(档案激活率,降级为诊断漏斗)**:当周新注册用户中,完成「建宠 + ≥1 条健康记录」全链路的比例(事件表 + `identity.users`)。读数即时,用于诊断激活链路(配合 H4),不作方向指标。
### 2.2 漏斗指标(随埋点上线即产出)
- **建宠三段漏斗**(§1.6 修订后):`page_viewed(pet_form)``pet_create_started``pet_create_succeeded`,各段按去重 userId、24 小时归因窗(v1 注册转化率同款口径)。「到达→动笔」流失指向入口与表单首屏,「动笔→成功」流失指向表单项与校验。
- **档案创建完成率** = `health_record_create_succeeded` / `health_record_create_started`,同口径,按 `recordType` 拆分——哪类表单流失最重是 UI 迭代的直接输入(`record_form` 到达段同理三段化)。
- 辅助:`*_create_failed``failureReason` 分布(`validation_error` 高 → 表单/文案问题;`network_error`/`server_error` 高 → 技术问题)。
### 2.3 护栏指标(M2 期间任何改动不得劣化)
| # | 护栏 | 口径 | 阈值(**待拍板**) |
| --- | --- | --- | --- |
| 1 | 并发冲突率 | `health_record_edit_failed(failureReason=conflict)` / 编辑尝试总数(= edit_succeeded + edit_failed | 建议 < 1%;持续高于阈值说明乐观锁粒度或客户端刷新策略有问题(对应 M2 验收「并发更新返回明确冲突」) |
| 2 | 越权信号 | `permission_denied` 事件数(绝对值) | 期望≈0;任何持续非零都是权限模型或客户端入口控制回归,P1 排查 |
| 3 | M1 存量指标不回退 | 登录成功率、会话恢复成功率(v1 §2.2/2.3 口径) | 不低于 M2 开工前 2 周基线均值 − 2pp |
| 4 | 埋点自身健康 | 事件丢失率 < 5%、对账偏差 < 5%(§6)、去重命中率 < 10% | 沿用 v1 实验前置条件阈值 |
| 5 | 崩溃率 | **暂缺采集手段**(无崩溃上报 SDK,引第三方违反 v1「不绑定未评审供应商」约束) | 占位待拍板:M2 是否接受用「会话异常中断率」(§5.1 sessionId 落地后可推算)代偿 |
---
## 3. M2 产品假设(本角色新增,可证伪,上线前登记)
**方法约定**:以下阈值是**上线前登记的判定线,不是 KPI**——判定线先于数据存在,防止事后看图说话(HARKing)。每条假设的观察窗口届满即出判定,三种结局:支持 / 证伪 / 数据不足(样本未达最低量,顺延一个窗口并注明)。所有假设的数据源都已在 §1.6 验证「字典可答」。上线第 1 周为尝鲜噪声期,除 H4 外一律剔除。
### H1:体重是最高频的记录类型(信息架构假设)
- **陈述**:稳定期内,`weight` 在四类记录的创建量中占比第一且 ≥ 35%。
- **判定指标**`health_record_create_succeeded``props->>'recordType'` 的分布占比(与 §6.2 对账 SQL 的 evt_side 同源;以事实表侧交叉验证)。
- **判定线**:支持 = weight 第一且 ≥ 35%;证伪 = 连续 4 周 weight 非第一,或占比 < 25%;中间地带 = 顺延观察。
- **窗口**:上线后第 2–5 周。
- **行动**:支持 → 记录入口默认落体重、快捷录入优化优先投给体重表单;证伪 → 按实际头部类型重排入口与 M3 表单优化优先级。
### H2:用户会为多只宠物建档(多宠价值假设)
- **陈述**:有宠用户中,拥有 ≥ 2 只宠物档案的占比 ≥ 20%。
- **判定指标**:主数据源为 `pet.pets` 事实表(按 owner 去重计宠物数——事实表无丢失率,作分布真值);`pet_create_succeeded.petIndex` 的 per-user 最大值作事件侧交叉验证。
- **判定线**:支持 = ≥ 20%;证伪 = < 10%1020% 顺延。
- **窗口**:上线后 4 周末读数。
- **行动**:支持 → 宠物切换器/多宠列表体验进 M3 优先级;证伪 → 多宠管理 UI 降级,`petIndex` 维度保留继续观察。
### H3:创建提醒的用户回访记录率更高(提醒价值假设)
- **陈述**:首记后 7 日内创建过 ≥ 1 条 `reminder` 类记录的用户,其 7 日回访记录率比未创建者高 ≥ 10pp。
- **判定指标**:§2.1 北极星 SQL 按「窗口内是否有 `recordType='reminder'` 的创建成功事件」分成两群,比较回访率之差(回访事件计算时**剔除 reminder 类型自身**,防止「建了提醒」同时既定义分群又充当回访,循环论证)。
- **判定线**:支持 = 差值 ≥ 10pp 且两群各 ≥ 100 人;证伪 = 差值 < 5pp 或倒挂;510pp 顺延。
- **窗口**:上线后 6 周(需 ≥ 2 个成熟队列)。
- **方法论警示**:这是**观察性对照,只能证明相关**——爱记录的用户本来就更可能建提醒(自选择偏差)。支持结论的正确用法不是宣布因果,而是把「默认引导创建提醒」列为**首个 A/B 实验候选**(§4.3),用随机化坐实因果后再全量。
- **行动**:支持 → 进 A/B 候选池;证伪 → 提醒功能保持工具定位,不投入引导资源。
### H4:建宠后会立即产生首条记录(激活链路假设)
- **陈述**:完成建宠的用户中,≥ 50% 在建宠后 24 小时内产生第一条 `health_record_create_succeeded`
- **判定指标**per user 的首次 `pet_create_succeeded` 与首次 `health_record_create_succeeded``server_ts` 差值分布中,≤ 24h 的占比。
- **判定线**:支持 = ≥ 50%;证伪 = < 30%3050% 顺延。
- **窗口**:上线后 4 周(含第 1 周——激活链路恰恰要看新用户首触行为)。
- **行动**:证伪 → 说明建宠成功页缺少「顺手记一笔」的引导落点,「建宠成功页引导首条记录」进 A/B 候选池(与 H3 候选竞争首实验席位,配合辅助指标 B 诊断);支持 → 激活链路健康,优化资源全部投向回访(北极星)。
---
## 4. A/B 实验:M2 不启动(判断成立),启动路线首次给出
### 4.1 M2 不启动的复核结论
初版判断**成立**:v1 前置条件截至今日一项未变绿——指标基线连一天真实数据都没有,此时分流实验只会产出噪声结论。M2 的正确动作是把漏斗测准、把 §3 的假设判定跑起来(观察性分析不需要分流基础设施)。但「不做」不等于「不规划」,前置条件与达成路线如下。
### 4.2 前置条件清单 × 预计达成迭代
| # | 前置条件 | 内容 | 责任侧 | 预计达成 |
| --- | --- | --- | --- | --- |
| 1 | 数据质量验收 | 丢失率 < 5%、对账偏差 < 5%、去重命中 < 10%、serverTs 覆盖 100%、无红线泄漏 | 数据(§6 巡检即验收手段) | **M2 内**(埋点上线 + 2 周巡检) |
| 2 | 指标基线 | §2 指标连续稳定产出 ≥ 2 周,形成均值与方差,与服务端日志交叉核对一致 | 数据 | **M2 末–M3 初** |
| 3 | 样本量规则成文 | 给定基线率、MDE、95% 置信度、80% 功效的样本量计算方法与查表;按实际 DAU 换算实验最短运行时长 | 本角色(纯文档) | **M3** |
| 4 | 稳定分流组件 | `hash(userId, experimentSalt) % buckets`,实验期内分组不变、跨端一致;登录前实验用 `anonymousId` 并定义登录后归并规则 | 后端 | **M3** |
| 5 | 曝光事件 | `experiment_exposed(experimentKey, variant)` 进字典;分析只统计实际曝光用户,杜绝按分配名单算分母 | 后端 + Flutter | **M3**(随 #4 |
| 6 | 实验设计模板与评审流程 | 假设、主指标、护栏、提前停止规则、多重比较校正约定 | 本角色(模板可先行) | **M3** |
| 7 | 护栏监控与回滚 | 护栏指标准实时监控 + feature flag 一键回滚 | 后端/DevOps | **M3M4** |
| 8 | 隐私合规复核 | 实验分组数据同守红线 | 每实验各一次 | 常态 |
**结论:M3 末 8 项可全绿,M4 具备启动首个 A/B 的条件。**
### 4.3 首实验候选与样本量现实检验
候选按 §3 判定结果二选一:H3 支持 → 「新用户默认引导创建提醒」;H4 证伪 → 「建宠成功页引导首条记录」。两者主指标都直接挂北极星或其激活前置,护栏用 §2.3 全套。
样本量现实检验(启动前必须重算,此处给数量级感):若激活率基线 40%、检出 +8pp 绝对提升、双侧 α=0.05、功效 80%,每组约需 600 个新建档用户,合计 ~1,200;以回访率(基线假设 25%、MDE +8pp)为主指标则每组约需 ~640,且每人多等 8 天成熟期。**若按届时 DAU 换算实验需运行超过 8 周,判定该实验不可行**,退回观察性分析并继续攒流量——这条止损线与实验本身一起在设计文档里预登记。
---
## 5. 两个遗留高优项的验收标准与对账方法
这两项是 M2 埋点数据可信的**前置**,排入 M2 第一波工单(先于档案功能挂接)。
### 5.1 sessionId 生命周期(session_tracker + WidgetsBindingObserver
现状:`analytics_service.dart` 第 55 行每事件 `const Uuid().v4()`,会话维度完全不可用(§0.1 偏差 1)。
**验收标准(全部满足才算关单)**
1. 新建 `lib/analytics/session_tracker.dart`,注册为 `WidgetsBindingObserver``AnalyticsService` 从它读 sessionId,删除每事件生成逻辑。
2. 语义三条(即报告 13 §4.0 定义):冷启动生成新 sessionId;`paused → resumed` 间隔 **> 30 分钟**生成新 sessionId**≤ 30 分钟**沿用原值。
3. 同一前台会话内产生的所有事件(跨不同 eventNamesessionId 完全一致。
4. sessionId 为 UUID,不落任何持久化存储(会话本该跨冷启动失效;`lastActiveAt` 时间戳可持久化用于判定,报告 13 §3.3 键位已预留)。
5. 单元测试 ≥ 3 例:冷启动新值 / 短后台沿用 / 长后台(注入时钟模拟 31 分钟)换新值。
6. 真机手测脚本:登录 → 退后台 5 分钟 → 回前台操作 → 退后台 35 分钟 → 回前台操作,库内应恰好出现 **2 个** sessionId,且切分点在长后台处。
**对账方法(上线后每日巡检 SQL,见 §6.3.1)**:每 sessionId 平均事件数。修复前该值恒等于 1;修复后应明显 > 1。告警口径:`distinct sessionId / 事件总数 > 0.9` 持续一天 = 生命周期逻辑未生效或回退。
### 5.2 page_viewed 路由埋点(RouteObserver
**验收标准**
1. `RouteObserver` 注册进 `MaterialApp.navigatorObservers``didPush`(含 `didPopNext` 返回露出)触发 `page_viewed`
2. `pageName` 是**编译期枚举**,v2 初始集合:`login` / `register` / `home` / `profile` / `pet_list` / `pet_detail` / **`pet_form`**(§1.6 修订新增)/ `record_form` / `record_detail`(随 M2 页面定稿增删,进字典说明);带参数路由必须归一化——任何 UUID/ID 出现在 pageName 或 referrer 中即验收失败(§1.3 红线第 5 条)。
3. `referrer` = 前一页 pageName,栈底/冷启动首页为 null。
4. 不在字典枚举内的路由(如 dialog、临时调试页)**不上报**,而不是报未知名(后端会整条 rejected,白白消耗队列)。
5. 单测/widget 测试:push 两页断言两条事件且 referrer 链正确;pop 返回断言 `didPopNext` 补报。
6. M1 存量四页(登录/注册/首页/个人中心)与 M2 新页一次性挂全。
**对账方法(§6.3.2**:两条 sanity 关系式——(a) 每个 sessionId 至少 1 条 `page_viewed`(进过 app 必然看过页面);(b) `page_viewed(pageName=login)` 日次数 ≥ `auth_login_succeeded + auth_login_failed` 的去重 sessionId 数(登录尝试必先到达登录页)。偏差持续 > 5% 告警。
---
## 6. 对账 SQL 草案 v2 增量
v1 的 5.2.1–5.2.5(登录/注册/刷新对账、红线扫描、技术指标)继续每日跑,本节只列**新增**。真值来源:M2 后端事实表。**表名以 M2 后端 DDL 定稿为准**,下文按开发计划域划分假定 `pet` schema`pet.pets``pet.weight_records``pet.vaccine_records``pet.health_events``pet.reminders`——若实际命名不同,替换表名即可,结构不变。
### 6.1 宠物创建对账
`pet_create_succeeded` 事件数 vs `pet.pets` 当日新建行数,UTC 日界,偏差 > 5% 告警(连续 2 日再升级,队列延迟说明同 v1 5.2)。
```sql
SELECT coalesce(p.day, t.day) AS day, coalesce(api_cnt, 0) AS api_cnt,
coalesce(evt_cnt, 0) AS evt_cnt,
round(abs(coalesce(evt_cnt, 0) - coalesce(api_cnt, 0))::numeric
/ greatest(coalesce(api_cnt, 0), 1) * 100, 2) AS diff_pct -- > 5 告警
FROM (SELECT date_trunc('day', created_at AT TIME ZONE 'UTC') AS day, count(*) AS api_cnt
FROM pet.pets GROUP BY 1) p
FULL JOIN (SELECT date_trunc('day', server_ts AT TIME ZONE 'UTC') AS day, count(*) AS evt_cnt
FROM platform.product_events
WHERE event_name = 'pet_create_succeeded' GROUP BY 1) t USING (day)
ORDER BY day;
```
### 6.2 健康记录创建对账(按 recordType 分型)
事件侧按 `props->>'recordType'` 分组,真值侧四张事实表 UNION 后带类型标签,逐类型对账——单独一类偏差大能直接定位是哪个表单的挂接点漏报。该 SQL 的 api_side 分布同时就是 **H1 的真值侧读数**
```sql
WITH api_side AS (
SELECT day, record_type, count(*) AS api_cnt FROM (
SELECT date_trunc('day', created_at AT TIME ZONE 'UTC') AS day,
'weight' AS record_type FROM pet.weight_records
UNION ALL
SELECT date_trunc('day', created_at AT TIME ZONE 'UTC'), 'vaccine' FROM pet.vaccine_records
UNION ALL
SELECT date_trunc('day', created_at AT TIME ZONE 'UTC'), 'health_event' FROM pet.health_events
UNION ALL
SELECT date_trunc('day', created_at AT TIME ZONE 'UTC'), 'reminder' FROM pet.reminders
) u GROUP BY 1, 2
),
evt_side AS (
SELECT date_trunc('day', server_ts AT TIME ZONE 'UTC') AS day,
props->>'recordType' AS record_type, count(*) AS evt_cnt
FROM platform.product_events
WHERE event_name = 'health_record_create_succeeded'
GROUP BY 1, 2
)
SELECT coalesce(a.day, e.day) AS day, coalesce(a.record_type, e.record_type) AS record_type,
coalesce(api_cnt, 0) AS api_cnt, coalesce(evt_cnt, 0) AS evt_cnt,
round(abs(coalesce(evt_cnt, 0) - coalesce(api_cnt, 0))::numeric
/ greatest(coalesce(api_cnt, 0), 1) * 100, 2) AS diff_pct -- > 5 告警
FROM api_side a
FULL JOIN evt_side e ON a.day = e.day AND a.record_type = e.record_type
ORDER BY day, record_type;
```
### 6.3 两个遗留项的健康巡检(§5 对账方法的可执行形式)
**6.3.1 sessionId 生命周期生效性**
```sql
SELECT date_trunc('day', server_ts AT TIME ZONE 'UTC') AS day,
count(*) AS events,
count(DISTINCT session_id) AS sessions,
round(count(DISTINCT session_id)::numeric / greatest(count(*), 1), 3) AS session_ratio
FROM platform.product_events
GROUP BY 1 ORDER BY 1;
-- session_ratio 接近 1.0(每事件一会话)= sessionId 仍是每事件生成,未生效/回退,告警
-- 修复后预期显著 < 0.5(每会话多事件)
```
**6.3.2 page_viewed 覆盖率**
```sql
-- (a) 无 page_viewed 的会话占比(进过 app 必看过页面,期望≈0)
SELECT date_trunc('day', server_ts AT TIME ZONE 'UTC') AS day,
round(100.0 * count(DISTINCT session_id)
FILTER (WHERE session_id NOT IN (
SELECT session_id FROM platform.product_events WHERE event_name = 'page_viewed'))
/ greatest(count(DISTINCT session_id), 1), 2) AS pct_sessions_without_pv -- > 5 告警
FROM platform.product_events
GROUP BY 1 ORDER BY 1;
-- (b) 登录页浏览 ≥ 登录尝试会话数(sanity)
SELECT coalesce(pv.day, la.day) AS day, coalesce(pv_cnt, 0) AS login_page_views,
coalesce(attempt_sessions, 0) AS login_attempt_sessions
FROM (SELECT date_trunc('day', server_ts AT TIME ZONE 'UTC') AS day, count(*) AS pv_cnt
FROM platform.product_events
WHERE event_name = 'page_viewed' AND props->>'pageName' = 'login' GROUP BY 1) pv
FULL JOIN (SELECT date_trunc('day', server_ts AT TIME ZONE 'UTC') AS day,
count(DISTINCT session_id) AS attempt_sessions
FROM platform.product_events
WHERE event_name IN ('auth_login_succeeded', 'auth_login_failed') GROUP BY 1) la
USING (day)
ORDER BY day;
-- login_page_views < login_attempt_sessions 持续出现 = 路由埋点漏报,告警
```
### 6.4 内容泄漏值级巡检(红线 regex 不扩的补防线,见 §1.3)
白名单字段的**值**若出现长自由文本,说明有人把备注/宠物名塞进了合法字段名里:
```sql
SELECT event_name, k AS prop_key, count(*) AS hits
FROM platform.product_events
CROSS JOIN LATERAL jsonb_each_text(props) AS kv(k, v)
WHERE server_ts >= now() - interval '1 day'
AND length(v) > 64 -- 字典 v2 所有枚举/数值字段值长远小于 64
GROUP BY 1, 2;
-- 期望恒为空集;命中即 P1:核对该字段是否被塞入内容数据并清洗
```
### 6.5 M2 新事件的匿名兜底巡检
M2 事件全部发生在登录后,`user_id` 为 NULL 即挂接点在 `identify()` 之前触发或时序 bug(同时会污染北极星分母,见 §2.1):
```sql
SELECT event_name, count(*) AS null_user_rows
FROM platform.product_events
WHERE event_name LIKE 'pet_%' OR event_name LIKE 'health_record_%'
GROUP BY 1 HAVING count(*) FILTER (WHERE user_id IS NULL) > 0;
-- 期望空集(退出后补冲刷的历史队列除外,占比应 < 1%)
```
---
## 7. 埋点基础设施 M2 扩展性评估
**结论:接收端与存储零改动,客户端小修三处,无需任何架构扩展。**(复核初版量级估算,成立。)
### 7.1 量级估算(不需要扩容的依据)
- 单用户日事件量:auth 域 ~3–5 条 + `page_viewed` ~8–15 条(路由埋点补齐后的最大增量来源)+ pet/health_record 域 ~38 条 ≈ **1530 条/DAU/日**,约为 v1 的 34 倍。
- 接收端:正常客户端 30 秒一批、批上限 50 条,日 30 条远填不满一批;限流 60 请求/5 分钟余量依旧十几倍。**`/api/v1/events` 契约、限流、64KB 上限均不动。**
- 存储:即便 1,000 DAU × 30 条 × 365 天 ≈ 1,100 万行/年,距报告 13 §2.3 的 5,000 万行分区阈值仍有数年余量。**v1 不分区的决策继续有效。**
- 客户端队列:500 条上限可容纳两周以上的离线积压(30 条/日),**不调**。`page_viewed` 是新的高频事件,唯一注意点:列表页快速进出可能瞬时产生密集事件,§5.2 验收第 4 条(字典外路由不上报)+ 详情页曝光而非列表曝光(§1.4 `health_record_viewed` 触发时机)已从源头限流。
### 7.2 需要落的三处客户端小修(随 M2 第一波工单,均对应 §0.1 实锤偏差)
1. **sessionId 生命周期**(§5.1,P0——不修则 M2 全部会话维度指标作废)。
2. **eventId 改回 UUIDv7**:现行 `Uuid().v4()`(第 50 行)去重仍有效,但 v4 随机主键使 `platform.product_events` 插入丧失时间局部性,量级上来后 B-tree 写放大;`uuid` 包本就支持 v7,一行改动,顺手修。
3. **动态 appVersion/osVersion**(引 `package_info_plus`/`device_info_plus`,报告 19 遗留第 6 项):M2 起指标要按版本切片看回归,硬编码 `1.0.0+1` / `android-14` / `ios-17` 会让版本维度全体失真;与本波一起落。
### 7.3 后端唯一改动
`EventDictionary` 白名单增补 10 事件 + 移除 `health_record_action`(§1.5 代码块可直抄),加集成测试各一例(沿用报告 19 §5.1 接入流程)。无表结构、无契约变更。
---
## 8. 待拍板清单(汇总)
| # | 事项 | 选项 | 本角色裁定/建议 |
| --- | --- | --- | --- |
| 1 | 北极星指标 | A:7 日回访记录率 / B:档案激活率 | **已裁定 A**(定义式落定于 §2.1,B 降级辅助诊断;PM 保留否决权,否决须给替代定义式) |
| 2 | 产品假设 H1–H4 判定线 | §3 各阈值 | 上线前由 PM 会签一次,会签后**冻结**,窗口届满前不得修改(防事后画靶) |
| 3 | 护栏阈值 | 并发冲突率 < 1%?M1 指标回退容忍 2pp? | 按 §2.3 默认值先跑,两周数据后复核 |
| 4 | 崩溃率护栏 | 无采集手段:接受「会话异常中断率」代偿 or 排期评审崩溃 SDK | M2 用代偿,SDK 评审进 M6 交付加固 |
| 5 | `pet_limit_reached` 枚举 | 产品是否设单用户宠物数上限 | 无上限则从枚举删除 |
| 6 | `entryPoint`/`pageName` 枚举终稿 | 待 M2 UI 设计稿定稿后收敛(`pet_form` 为本版硬性新增,见 §1.6) | 埋点工单开工前由 UI + 本角色对齐一次 |
| 7 | `health_record_action` 移除 | 后端白名单直接删 vs 保留标 deprecated | **直接删**(零客户端引用已核实,零兼容成本,见 §1.2) |
| 8 | A/B 启动路线 | §4.2 八项前置 × 迭代 | M3 末全绿、M4 首实验;候选依 H3/H4 判定结果二选一 |
---
## 附:M2 埋点工单拆分建议(按依赖排序)
1. **Flutter P0 前置**session_tracker(§5.1+ eventId v7 + 动态设备信息(§7.2)——先于一切新事件。
2. **Flutter**`RouteObserver` + `page_viewed` 全页面挂接(§5.2,含 `pet_form`),M1 存量四页一并补齐。
3. **后端**`EventDictionary` v2 增量(§7.3)——可与 1、2 并行。
4. **Flutter**:档案功能开发时按 §1.4 挂接 10 个新事件(强类型封装先行)。
5. **数据**:§6 五组对账 SQL + §2.1 北极星 SQL 入巡检;上线首周每日人工看 §6.3 两项(遗留修复的生效性验证)。
6. **本角色**:H1–H4 判定线 PM 会签(拍板 #2)→ 冻结登记;M3 初产出样本量规则文档与实验设计模板(§4.2 #3#6)。