Files
jkooe f4e24c2c95 feat(events): 最小埋点 5 事件的闭环 —— 白名单、有界、写失败不阻断(M5)
## 三处前置此前**全部不存在**(我实测,不是文档说)

```
shared/events.js                     → 不存在
deploy/migrate.sql  CREATE TABLE events → 0 命中
server/ 下 "api/events"               → 0 命中
```

所以在这之前任何"事件名单差集为空"的断言都是**恒绿的空断言**:文件不存在 → 读到空
集合 → 差集为空,而"文件不存在"与"文件里没有多余事件"在输出上是同一个东西:空。

## 服务端

- `shared/events.js`:5 个名字的**唯一机器副本**(权威在 SPEC §6.1.1)。净化做的是
  **形状白名单**而不是字段名白名单 —— SPEC 只锁了事件名、没定义逐事件 props,此时
  在这里发明一套字段名等于实现方单方面定契约,然后让客户端照着写。形状约束
  (键名 `^[a-z][a-z0-9_]{0,31}$` / 值只接受标量 / ≤12 键 / ≤2KB / 敏感键名直接丢)
  能挡住"埋点变成第二张用户表"这条路上最现实的风险,且不假装知道产品要什么。
- `POST /api/events`:未知名与非法条目**丢弃并计 dropped**(AC-20,不是 400)、
  超 20 条只受理前 20(AC-21)、**写库失败仍 202**(AC-22)。认证可选但
  **带废令牌一律 401**(降级成匿名等于"发个废令牌就关认证");
  `body.account_id` 自报**被忽略** —— 否则谁都能往别人账号的漏斗里灌数,而漏斗是
  拿来算率的,被灌了就没有意义。
- 管理端 `/api/admin/events/summary`:只出按事件名的计数与首末时间。它的判据不在
  "能查出来",而在**查不出来** —— 这个端点没有任何办法取回某一条事件的内容。

## 客户端:必须有真实产生方,否则这张表永远是空的

这是本轮最容易"看起来交付了"的地方:端点全绿、表建好了,而**没有任何上报** →
表恒空、漏斗没有分子分母,而所有测试照样通过。**"以空为通过"的形态里这一种最难发现,
因为每一层单独看都对。**

`client/lib/host/event_reporter.dart` + `main.dart` 两个真实触发点(`app_open` 在配置
就绪、首帧将出时;`first_frame_shown` 用 `addPostFrameCallback`)。三条纪律:
不阻断业务(失败吞掉)、不重试(补报时序已经错了)、有界(丢最旧的)。

`app_open` **不能**放在 `main()` 最前面 —— 那时还不知道业务地址(`businessUrl` 来自
用户配置)。所以它落在"配置已就绪、首帧将出"这一刻;语义仍是"打开了应用",且这一批
能真的发出去。

## 建表只有一个落点

`events` 只写进 `deploy/migrate.sql`,`init.sql` 用 `\i /migrate.sql` 引用它 ——
这是本仓库既有约定(新表进 migrate,init 引用)。我最初给执行方的判据是"两个文件都
必须有"——那会把一份定义复制成两份,是错的,按约定改正。

`account_id` 用 `BIGINT REFERENCES users(id) ON DELETE SET NULL` 而不是 SPEC §6.1
原写的 `TEXT`:`users.id` 是 BIGSERIAL,TEXT 会让"按账号算率"每次都 `::bigint` 强转,
转不动时(LEGACY 自报串)只能静默留 NULL,而 NULL 与"匿名上报"在表里长得一模一样。
`ON DELETE SET NULL` 让注销后**保留计数、解除关联**(CASCADE 会让历史漏斗在注销那一刻
缩水,分母变了率就不可比)。

## 测试

`tests/events-test.js` **29 项全绿**,已接入 `run-all.sh`。关键一条是 **A7「表里真的
长出了行」**——没有它,前面每条都能在一个"什么都没落库"的实现上全绿。
AC-22 不是写注释:把 `events` 表临时改名造一次真实写失败,断言仍 202 **且**
`accepted=0` **且** 带 `write_failed` **且** 恢复后行数未变(只断 202 不够 ——
任何实现都会 202,因为 catch 住了)。

客户端 `event_reporter_test.dart` 6 项全绿,其中一条钉住"先抽干再发":发送期间
`track` 进来的新事件属于下一批,不被这一批的失败连坐(否则是"失败一次丢两批")。

回归:permissions 60/0、account 75/0、schema 116/0、admin-api 80/0、认领 57/0、
档位与历史 99/0;客户端全量 **595 通过 / 2 失败**,两条失败均为
`libpmon_core.dylib` 未编译(Rust 核心未构建,本机环境问题,与本次改动无关)。
2026-11-08 19:46:29 +08:00

134 lines
5.9 KiB
JavaScript
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.
'use strict';
/**
* 埋点(M5)的事件名与净化规则 —— **5 个事件名的唯一机器副本**。
*
* ── 权威与副本 ─────────────────────────────────────────────────────
* 权威名单在 `docs/SPEC-v2.md` §6.1.1(**改名字只能从那里发起**)。本文件是它的
* 机器副本,承担差集的另一侧。`openapi.yaml` 与设计稿**刻意不复制**:`openapi.yaml`
* 的 `EventsBatchRequest.name` 明确写"本文件不复制"且不设 `enum` —— 一旦设 `enum`,
* AC-20 要求的"整条丢弃 + 计入 `dropped`"就会在网关层被变成 400,
* **埋点失败被升级成业务失败**,正好违反 AC-22 的精神。
*
* ── 为什么不做"逐事件 props 白名单" ────────────────────────────────
* SPEC 只锁了 5 个事件名,**没有定义逐事件的 props 字段**。此时若在这里发明一套
* (`{app_open: ['platform','app_version']}`),等于把"产品还没定的字段"由实现方
* 单方面定死,然后让客户端照着写 —— 那份字段名会立刻变成事实上的契约,而它没有
* 任何依据。所以这里做的是**形状白名单**,不是字段名白名单:
* · 键名必须 `^[a-z][a-z0-9_]{0,31}$`;
* · 值只接受标量(string / 有穷 number / boolean),数组与嵌套对象**整条丢弃**;
* · 单条 props 至多 12 个键、序列化后至多 2KB;
* · 敏感键名(口令/令牌/邮箱/电话/设备名…)**直接丢弃该键**(隐私边界见 SPEC §10)。
* 形状约束能挡住"埋点变成第二个用户表"这条路上最现实的风险,且不假装知道产品要什么。
*
* ── 净化的方向:丢弃,不报错 ──────────────────────────────────────
* 净化与白名单的失败一律是"丢弃这一条/这个键",端点**永远 202**(AC-20/AC-22)。
* 埋点不是业务:让埋点失败去阻断用户动作,是本末倒置。
*/
/** 5 个事件名(逐字,**不要改名、不要增删、不要排序"优化"**)。权威:SPEC §6.1.1 */
const EVENT_NAMES = Object.freeze([
'app_open',
'first_frame_shown',
'quota_blocked',
'upgrade_click',
'upgrade_requested',
]);
const EVENT_NAME_SET = new Set(EVENT_NAMES);
/** 单次上报的受理上限(AC-21:超出部分计入 dropped,不是 400) */
const MAX_BATCH = 20;
/** 单条事件的 props 形状上限 */
const MAX_PROP_KEYS = 12;
const MAX_PROP_JSON_BYTES = 2048;
const MAX_PROP_STRING_LEN = 200;
/** 键名合法形状 */
const PROP_KEY_RE = /^[a-z][a-z0-9_]{0,31}$/;
/**
* 敏感键名:命中即丢弃该键。
*
* 隐私边界(SPEC §10)写明不采集键盘/屏幕/浏览记录/通讯录/位置。这里额外把
* "能反查到人的标识"和"凭据"一律挡掉 —— 它们出现在埋点里从来不是"统计需要",
* 而是某次调试顺手打进去的,然后永久留在库里。
*/
const SENSITIVE_PROP_KEYS = new Set([
'secret', 'password', 'passwd', 'token', 'access_token', 'refresh_token',
'email', 'phone', 'mobile', 'device_name', 'display_name', 'ip',
'authorization', 'cookie', 'session',
]);
function isScalar(v) {
return typeof v === 'string' || typeof v === 'boolean'
|| (typeof v === 'number' && Number.isFinite(v));
}
/**
* 净化 props。返回 `{}` 表示"没有可留的"(调用方按"无 props"落库,不算丢弃)。
* 只丢键、不丢整条:一个多余键不该让整条事件消失(丢整条会丢分母)。
*/
function sanitizeProps(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const out = {};
let n = 0;
for (const [k, v] of Object.entries(raw)) {
if (n >= MAX_PROP_KEYS) break;
const key = String(k).toLowerCase();
if (!PROP_KEY_RE.test(key)) continue;
if (SENSITIVE_PROP_KEYS.has(key)) continue;
if (!isScalar(v)) continue; // 数组/对象整条丢弃
if (typeof v === 'string' && v.length > MAX_PROP_STRING_LEN) continue;
out[key] = v;
n += 1;
}
if (JSON.stringify(out).length > MAX_PROP_JSON_BYTES) return {};
return out;
}
/**
* 规整一批上报。**只返回可落库的条目与丢弃数**,不抛错。
*
* @param {unknown} raw 请求体里的 `events`
* @returns {{ accepted: Array<{name:string, props:object, ts:Date|null}>, dropped: number }}
*/
function normalizeBatch(raw) {
const arr = Array.isArray(raw) ? raw : [];
const accepted = [];
let dropped = 0;
for (let i = 0; i < arr.length; i += 1) {
const item = arr[i];
// 超过上限的部分一律计 dropped(AC-21)。注意这里**继续循环**而不是 break:
// break 之后 `dropped` 只加到上限那一刻,剩下的条目既不受理也不计数 ——
// 于是 accepted + dropped !== 上报条数,统计口径对不上。
if (i >= MAX_BATCH) { dropped += 1; continue; }
if (!item || typeof item !== 'object' || Array.isArray(item)) { dropped += 1; continue; }
const name = typeof item.name === 'string' ? item.name.trim() : '';
if (!EVENT_NAME_SET.has(name)) { dropped += 1; continue; } // AC-20
// ts 是**客户端提示**,落库时间以服务端 now() 为准(时钟不可信)。
// 这里只把它当"可选元数据"保留下来前先做形状校验:非法的直接不带。
let ts = null;
if (typeof item.ts === 'string' || typeof item.ts === 'number') {
const d = new Date(item.ts);
if (!Number.isNaN(d.getTime())) ts = d;
}
accepted.push({ name, props: sanitizeProps(item.props), ts });
}
return { accepted, dropped };
}
module.exports = {
EVENT_NAMES,
EVENT_NAME_SET,
MAX_BATCH,
MAX_PROP_KEYS,
MAX_PROP_STRING_LEN,
SENSITIVE_PROP_KEYS,
sanitizeProps,
normalizeBatch,
};