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

41 KiB
Raw Permalink Blame History

第二迭代埋点与实验规划(宠物健康档案)

角色:Experiment Tracker(本版为角色复核定稿;初版由通用 agent 代拟,已整体接管) 日期:2026-09-07 前序:iteration-1 05-experiment-tracking-plan.md(事件与指标规划)、13-tracking-implementation-spec.md(工程规范与字典 v1)、19-analytics-implementation-report.mdM0 简化版落地实况) 依据: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 条可证伪产品假设 H1H4(初版完全缺失),每条带判定指标、阈值、数据源、观察窗口与证伪后行动,见 §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_eventsFlyway V2v1 不分区,触发分区阈值约 5,000 万行) 报告 13 §2
Flutter 采集 AnalyticsService 已挂 3/5 挂接点(登录/注册/退出);page_viewedhealth_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_caseeventVersion 起始 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 白名单直接移除该条目(连同 actionTyperecordType 作为属性名由 §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.pageNamereferrer 必须是归一化枚举——/pet/3f8a… 一律归一为 pet_detail,禁止把宠物/记录 UUID 混进页面名。

红线正则(password|token|secret|phone|mobile|email|credential|idfa|gaid本轮不扩:加 name/note 类宽泛词会误伤 pageNamerecordType 等合法字段;内容字段靠白名单剥离兜底,另新增值级巡检(§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 修订),每次进入记一次 entryPointprofile_empty_state / pet_list / post_register_guide,枚举待 UI 定稿收敛)
pet_create_succeeded 客户端收到建宠接口成功响应(code=0)后(漏斗事件 durationMsspecies(枚举)、petIndex(该用户第几只宠物,int,H2 假设的直接数据源)
pet_create_failed 失败响应 / 超时 / 本地校验拦截 failureReasonerrorCode(可空)、httpStatus(可空)、attemptSeq

pet_create_failed.failureReasonvalidation_errorpet_limit_reached(若产品设上限,待拍板:无上限则删此枚举)、rate_limitednetwork_errorserver_error

说明:示例名 pet_created 不符合 v1「结果后缀」惯例,按 <域>_<动作>_<结果> 正名为 pet_create_succeeded 系列。

健康记录创建(health_record 域)

事件名 触发时机 专有属性
health_record_create_started 进入某类记录的创建表单并产生首次输入 recordTypeentryPointpet_detail / record_list / reminder,待 UI 定稿收敛)
health_record_create_succeeded 收到创建接口成功响应后(漏斗事件,北极星与 H1/H3/H4 的核心数据源) recordTypedurationMsphotoCountint,无照片为 0
health_record_create_failed 失败响应 / 超时 / 本地校验拦截 recordTypefailureReasonerrorCodehttpStatusattemptSeq

failureReasonvalidation_errorpermission_deniednot_foundrate_limitednetwork_errorserver_error

记录浏览 / 编辑 / 删除

事件名 触发时机 专有属性
health_record_viewed 记录详情页可见(列表滚动曝光不算,防事件洪水) recordTypesourcerecord_list / pet_detail / reminder
health_record_edit_succeeded 编辑保存成功响应后 recordTypefieldCount(本次变更字段数,int,可空)
health_record_edit_failed 编辑保存失败 recordTypefailureReason(含 conflict)、errorCodehttpStatus
health_record_deleted 删除成功响应后(仿 auth_logout 单事件风格;删除失败不埋,靠服务端接口错误率观测) recordType

宠物列表/详情的浏览不设 pet_viewed——由 page_viewedpageName = 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 白名单增量(工单可直接抄):

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_createhealth_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_succeededhealth_record_create_succeeded 的 userId + serverTs(有)。全部假设可由本字典 + M2 事实表回答,维度判定通过。


2. M2 指标体系(北极星定义式落定 + 漏斗 + 护栏)

统计口径沿用 v1serverTs 划 UTC 日界;主体去重用 userIdM2 事件全部发生在登录后,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_succeededserver_tsmin),非「本周期首条」 回访衡量习惯养成,只对真正的新记录用户有意义
分母 firstRec 落在 ISO 周 wUTC)内的去重 userIduser_id IS NULL 的事件不计入(应为空集,§6.5 兜底) 按首记周分队列,队列间互斥
分子 分母中,在 day(firstRec)+1 至 day(firstRec)+7UTC 自然日,不含首记当日)内再产生 ≥1 条 health_record_create_succeeded 者;任意 recordType、任意宠物均算 排除首记当日是关键:不排除则首次使用时同会话批量录入 3 条体重也算「回访」,指标失去留存含义
回访事件范围 仅创建成功事件;viewed/edit 不算回访 北极星衡量「持续产生记录」,浏览是弱得多的信号,混入会稀释
删除处理 记录事后被删不影响计数(行为已发生) 事件表不可变语义
队列成熟期 队列须等到 day(firstRec)+8(UTC)才可出数;未成熟队列不发布 防止半熟队列读数系统性偏低
去重 全程 userId;多设备同账号合并计 会话/设备维度不参与——刻意使北极星不依赖 sessionId(偏差 1 修复与否不污染北极星)

出数 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_startedpet_create_succeeded,各段按去重 userId、24 小时归因窗(v1 注册转化率同款口径)。「到达→动笔」流失指向入口与表单首屏,「动笔→成功」流失指向表单项与校验。
  • 档案创建完成率 = health_record_create_succeeded / health_record_create_started,同口径,按 recordType 拆分——哪类表单流失最重是 UI 迭代的直接输入(record_form 到达段同理三段化)。
  • 辅助:*_create_failedfailureReason 分布(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_succeededprops->>'recordType' 的分布占比(与 §6.2 对账 SQL 的 evt_side 同源;以事实表侧交叉验证)。
  • 判定线:支持 = weight 第一且 ≥ 35%;证伪 = 连续 4 周 weight 非第一,或占比 < 25%;中间地带 = 顺延观察。
  • 窗口:上线后第 25 周。
  • 行动:支持 → 记录入口默认落体重、快捷录入优化优先投给体重表单;证伪 → 按实际头部类型重排入口与 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_succeededserver_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,注册为 WidgetsBindingObserverAnalyticsService 从它读 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.navigatorObserversdidPush(含 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 schemapet.petspet.weight_recordspet.vaccine_recordspet.health_eventspet.reminders——若实际命名不同,替换表名即可,结构不变。

6.1 宠物创建对账

pet_create_succeeded 事件数 vs pet.pets 当日新建行数,UTC 日界,偏差 > 5% 告警(连续 2 日再升级,队列延迟说明同 v1 5.2)。

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 的真值侧读数

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 生命周期生效性

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 覆盖率

-- (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)

白名单字段的若出现长自由文本,说明有人把备注/宠物名塞进了合法字段名里:

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):

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 产品假设 H1H4 判定线 §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. FlutterRouteObserver + 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)。