# 匿名使用统计 本文定义**默认开启**的匿名使用统计:它回答「有多少人在用、用的哪个版本、在哪些地区、哪些功能真被用了」,且**不采集任何用户内容**。 隐私总原则见 [security-privacy.md](./security-privacy.md);本文是该原则下唯一允许出站的非模型流量,其余非模型流量一律不出站。 > **状态:客户端与服务端两侧均已落地(2026-09)。** > > - **客户端**:core 的安装标识 / 队列 / 直连发送、管理接口的预览与上报都已回到仓库,见 §12 与 §14。 > - **服务端**:`apps/apis/`(Worker)的下游形态已定稿。转发链路是「契约 → `TelemetrySink` 抽象 → `sinks/analytics-engine.ts`」,端的存储是 **Cloudflare Analytics Engine**(与本 Worker 同一个账号里的一张数据集),见 §6 与 §7。 > - **存储侧在 2026-10 换过一次**:上一版是 Aptabase(欧盟区),对方按「免费用量超出」锁掉了整个账号(摄取端点回 `400 Owner account is locked.`,所有事件静默丢弃)。换掉的直接原因是这一条——新下游是我们自己账号里的一个数据集,**结构上不存在「被第三方锁掉」这种失败**。代价写在 §8.1。 > - **共用契约**:`packages/contracts/source/telemetry.ts` 是客户端与 Worker 共用的唯一一份定义,**只描述事实,不描述去向**(§5.2)。 > - **默认开启**:`telemetryEnabled` 默认 `true`,**界面上没有任何入口**,控制台里不看、不开关、不提示(§1、§13)。 > > 本文§1–§5 与§12–§15 描述规范与客户端行为,§6–§11 描述服务端实现。 ## 1. 定位与边界 ### 为什么要有它 本地工具最难回答的问题是「到底有没有人在用、用的是不是我以为的那个功能」。请求日志统计的是**用户自己的请求**,它回答不了跨用户的聚合问题。使用统计补的就是这一块。 ### 与产品承诺的关系 本文与 [security-privacy.md](./security-privacy.md) 在这一条上是同一个口径:产品**默认上报匿名统计**——设置项 `telemetryEnabled` 默认 `true`,界面上没有任何入口。取舍如下: - **默认开启**:`telemetryEnabled` 默认 `true`,新装用户即产生统计流量; - **完全不出现在界面里**:不做开关、不做说明、不做预览——采集范围不变,但它是产品自己的行为,不向用户抛出一个待办; - **仍然可关**:字段本身保留,可由管理接口(`PUT /api/settings`)改回 `false`(即刻停止后续采集,不需要重启),也留作将来需要时的开关位; - **可自证**(开发档能力):管理接口的预览返回本地队列里的真实报文,与真正上报走同一条组装路径;它只在需要排查时被调用,不在界面上占位。 > 这是「默认采集、如实告知」,不是「默认同意」:所有隐私底线(不采集内容、不做设备指纹、不做用户画像、保留期可删)都不因默认开启而放宽。承诺写在 [security-privacy.md](./security-privacy.md) 里,而不是摆在每一屏上。 ### 为什么要经过自建端点 统计流量不直连第三方分析服务,而是**先到我们自己的一个端点**。直接好处是密钥不落在客户端、报文可以先校验再转发;真正长期的好处是**客户端里写死的只有一个自有域名**,后端用什么、数据存哪里、存多久全都可以换,而不需要用户更新。论证与约束在 §6。 ### 与出站代理的关系 使用统计**永不走用户的出站代理设置**,见 [outbound-proxy.md](../proxy/outbound-proxy.md)「非目标」:那条规则不代理遥测流量。实现上它是一个独立的直连连接器,理由有两条: 1. 用户配的代理是为**模型请求**服务的,把统计流量混进去会让代理日志里出现他没预期的目标; 2. 统计上报是「尽力而为」的流量,不该因为用户代理配置错误就永久失败,也不该在用户排查代理问题时被当成干扰项。 ### 开发档不上报 `development` 环境**永远不上报**,与开关无关。开发期产生的启动次数会污染真实数据,而且开发档的数据目录(`.osw-development`)本来就是会被反复重置的。 ## 2. 目标问题 首版只需回答七个问题。**这份清单就是需求边界**:不在表里的问题,首版不为其增加字段。 | # | 问题 | 观察单位 | |---|------|---------| | 1 | 用户趋势(活跃安装数随时间) | 去重安装 | | 2 | 地区分布 | 去重安装 | | 3 | 版本分布 | 去重安装 | | 4 | 语言分布 | 去重安装 | | 5 | 平台构成(系统 + 架构) | 去重安装 | | 6 | 功能使用排行 | 事件次数 | | 7 | 使用强度(一共处理了多少任务) | 事件次数 | **首版只有下游一层存储,我们不自己存一份。** 「去重安装」是一个安装标识的去重计数,不是自然人的数量(见 §4)。取舍见 §8 与 §10。 ## 3. 红线:绝不采集 以下内容**任何情况下都不出站**,不因开关、不因调试模式、不因错误上报而例外: | 类别 | 具体项 | |------|--------| | 请求内容 | 请求体、响应体、提示词、文件内容、模型输出、SSE 帧 | | 凭据 | API Key、Authorization、Cookie、Set-Cookie、代理账号密码、密钥环条目 | | 供应商标识 | Provider 名称、**baseUrl / 端点地址**、供应商包内容 | | 本机标识 | 用户名、主机名、机器 ID、MAC 地址、数据目录路径、文件名 | | 请求标识 | `requestId`、`attemptId`、逻辑模型 ID、工作流图 ID、改写规则 ID | | 内容规模 | Token 用量、请求耗时、错误原文——它们是**用户的业务数据**,不是产品使用统计 | | 精确位置 | 客户端不得上报任何地理信息;地区由服务端按 Cloudflare 边缘的 `CF-IPCountry` 写成 `country` 列,**地址本身从头到尾没被读过**,见 §7 | | 用户画像 | 不做用户级关联:报文里的标识只用来**去重计数**,下游里只有事件、没有「人」这个实体,见 §7 | 供应商 baseUrl 单独强调一次:用户自建中转的地址本身就是敏感信息,泄露它是实打实的隐私事故。因此事件属性里**没有位置能随手塞进一段内容**:所有属性都是固定枚举、布尔或分桶(见 §5.3),这条红线由契约的形状保证(§5.2 的「只描述事实」原则),不是靠转发时的过滤。 **与规模有关的那一项采集只有一个「处理了多少」,而且它连属性都没有。** §2 第 7 条问的是「这个工具到底有没有在被用」。它的做法是:**每成功处理一次请求就发一条 `request_completed`,任务数就是这条事件的事件次数**——数量由「事发了多少次」表达,而不是把数字写进某个属性(属性里本来也没有位置能放数字,见 §5.3)。它与本表「内容规模」一行的区别正在于此:token 用量、耗时与错误原文描述的是用户**业务内容**的特征(做的是什么、有多大、成不成),而「一共处理了多少次」只描述这个工具被用了多少。 它还刻意只发**成功**的:失败与被取消的请求不计入,否则「处理了多少任务」这个数就不再是那个意思了。 **模型名同样不在采集范围内。** 它看上去是个有价值的信号(「当下什么模型热门」),但它是**用户自己写下的一段字符串**,可能带着私有中转的标注或人名的后缀——正是上表第一条下半行的红线。想回答的是「哪些供应商 / 哪些协议在被用」,那是另一类可以做成闭集的问题(见 §15)。 ## 4. 安装标识 ### 本地标识 首次启用统计时在数据目录生成一个随机 UUID v4,写入 `telemetry-id`(权限 `0600`),与 `instance.lock`、`secrets.json` 同级。**不写进配置数据库**:它不该跟着用户配置一起被导出、备份或进供应商包。 删掉这个文件的下一个行为是「换了一台新设备」——这是正确语义,也是用户重置身份的现成手段。 ### 上报的就是这个标识 上报时直接把本地 UUID 当 `installId` 发出,**不派生、不轮换**: - 格式就是标准 UUID v4,下游可以直接拿它当「谁」用; - **长期稳定**,所以「活跃安装数」就是它的去重计数,跨天的活跃、留存、多日累计全部现成可用; - 服务端不持有任何可与真人对应的映射,也没有第二份标识。 **取舍是明确的:标识长期稳定,意味着下游里同一台设备的多个事件是可关联的。** 换来的是跨天分析全部现成,且客户端里少掉一整套派生逻辑。首版接受这个取舍,理由有三条: 1. 标识本身不与任何个人信息相关——没有账号、没有邮箱、没有设备指纹,删掉 `telemetry-id` 即换新身份; 2. 标识只在采集真正开启时才创建(开发档、被关闭时都不创建),而默认开启意味着正式版安装都会有它; 3. **轮换换不到真正的匿名,却会把留存报表全部废掉。** 按天轮换意味着下游里每天都出现一批「新安装」,留存、漏斗、活跃全都失去意义;而下游本来就是一个长期保留标识的系统,轮换只是把「我们手里不可关联」换成「它手里可关联但报表不可用」,净亏。 真正防「画像」的那一层不在标识上,而在**下游没有「人」这个实体**(§7):标识是可关联的,但下游里只有事件,没有可以挂上画像的对象。这两件事要分开看——前者是可选的取舍,后者是承诺。 ### 已知的取舍 同一台机器上的多个用户、多个数据目录,会被算成多个安装。本地工具没有可靠的「人」的概念,也不该为了统计去引入一个;**安装数就是安装数**,不假装它是用户数。 ## 5. 事件模型 ### 5.1 白名单 事件名与属性名都是**闭集**,定义在 `packages/contracts/source/telemetry.ts`,客户端、Worker、文档三处引用同一份定义。不在闭集里的事件名或属性名**在 Worker 侧被拒绝**,不是警告、不是降级,而是整批打回——这样客户端误传 `baseUrl` 之类的字段时,它在服务端进不了库。 ### 5.2 信封 每条事件都带这些字段,由客户端填写: | 字段 | 含义 | 约束 | |------|------|------| | `name` | 事件名 | 见 §5.3 | | `occurredAt` | 事件发生时间(本地时钟的毫秒时间戳) | 服务端会按接收时间二次校验合理性 | | `installId` | 本机匿名安装标识 | UUID v4 形状,不派生、不轮换 | | `version` | 应用版本 | 由宿主注入,见 §12 | | `os` | `win32` / `darwin` / `linux` | 枚举 | | `arch` | `x64` / `arm64` / `ia32` | 枚举 | | `locale` | `en` / `zh-CN` | 枚举,取用户实际界面语言,非系统语言 | | `runtime` | `desktop` / `cli` | 枚举 | **契约只描述事实,不描述去向。** 这是这一层唯一的设计原则,也是它叫「信封」而不是「公共字段」或「用户属性」的原因:那些词是某个后端的词汇。契约里不出现 `client_id`、`user_properties`、`params`、`event` 这类名字,也不出现「每事件最多 N 个属性」「某值最多 36 字符」这类**由某个下游的容量反推出来的数字**。写每个字段时问一句:**它在描述发生的事,还是描述它要去哪?** 后者一律不加(§6)。 违反这条原则的代价是具体的:一旦契约里出现「某个字段是用户属性」这种说法,换一个存储方案就变成一次**协议变更**——而协议是双方共用的,改它意味着客户端与 Worker 要同时升级,而已经装在用户机器上的旧版本没有第二次机会(§6)。 ### 5.3 事件目录(首版) 事件名是**发出去就改不了的词表**(§「只增不删」),所以它得一次就说清,而且在一张表里读起来 是同一种句式。规则有五条,定义与可执行的版本都在 `packages/contracts/source/telemetry.ts` (并以 `telemetry.test.ts` 逐个检查): 1. **形态是 `<主体>_<谓词>`。** 全小写 ASCII,只用字母、数字与下划线,以字母开头,不带 `$` / `_` 前缀,也不带 `-`、`.`、空格、版本号:`provider.created` / `ProviderCreated` / `v2_provider_created` 都不是这里的样子。 2. **主体是产品名词的单数**,是这件事的主角(`provider` / `route_mode` / `request`)。 不是界面元素,不是函数名,也不是「谁点的」(`provider_created`,而不是 `createDialogOpened`)。 3. **谓词是过去分词。** 事件永远描述已经发生的事实:祈使式 / 现在时(`provider_create`) 在说「将要」,动名词(`provider_creation`)在说「一个东西」。唯一看似例外的是 `run` (过去分词同形),而它恰恰会造成歧义:`provider_test_run` 读起来是「一次测试运行」, 而这条事件说的是「测试过了」——所以它叫 `provider_tested`。 4. **谓词取自一张封闭的动词表**,一个意思只用一个词: | 谓词 | 含义 | 现有用例 | |------|------|---------| | `started` | 进程 / 服务开始运行 | `app_started` | | `finished` | 一段流程走到终点(含被跳过) | `onboarding_finished` | | `failed` | 进程 / 服务以失败告终 | `service_start_failed` | | `completed` | 一个工作单元被完整服务完(成功) | `request_completed` | | `created` | 一个新的持久实体出现 | `provider_created` / `model_created` / `rewrite_rule_created` | | `changed` | 一个既有设置 / 状态的取值变了 | `route_mode_changed` | | `tested` | 一次探测性执行得出了结论 | `provider_tested` | | `executed` | 一个执行步骤跑过 | `workflow_node_executed` | | `used` | 一项产品能力被实际用上 | `protocol_conversion_used` | | `happened` | 自动发生的中性事实(不是成功、不是失败,也不是谁的动作) | `failover_happened` | 表外的动词要先加表、再加事件,两件事必须同一次改动。**同义词不许并存**:「添加一个 模型端点」与「新建一个 Provider」是同一件事,所以都用 `created`,不留 `added` 这种同义 词让读的人去猜它们的差别。 5. **属性名也是小写下划线**(`node_kind`,不是 `nodeKind`)。信封字段是刻意的例外:那几个 是 camelCase,因为它们由我们自己的代码填、在两端都是同名的 TypeScript 标识符;而事件名与 事件属性是要长期发到线上、给分析侧看的**词表**。同名属性在不同事件里必须同义、同词表 (`kind` 在 `provider_created` 与 `rewrite_rule_created` 里都是 `builtin` / `custom`)。 | 事件名 | 触发时机 | 属性 | |--------|---------|------| | `app_started` | 服务启动完成 | — | | `service_start_failed` | 启动最终失败 | `reason`: `instance_lock` / `port` / `database` / `other` | | `onboarding_finished` | 引导走完或跳过 | `skipped`: 布尔 | | `route_mode_changed` | 生效模式切换 | `mode`: `workflow` / `rules` | | `provider_created` | 新建 Provider(手工新建与导入供应商包都算,按名覆盖既有 Provider 不算) | `kind`: `builtin` / `custom` | | `model_created` | 新建 ProviderModel 的一个端点(界面上叫「添加模型」,导入包时按端点逐条) | `protocol`: `openai-completions` / `openai-responses` / `anthropic-messages` | | `provider_tested` | 连接测试中被测的一个目标得出了结论 | `result`: `success` / `failed` | | `rewrite_rule_created` | 新建请求改写规则 | `kind`: `builtin` / `custom` | | `protocol_conversion_used` | 一次请求发生协议转换 | `from` / `to`: 同上三值协议枚举 | | `failover_happened` | 一次请求发生故障转移 | `attempts`: `2` / `3` / `4+` | | `workflow_node_executed` | 工作流节点真的执行了一回 | `node_kind`: 节点类型枚举 | | `request_completed` | 一次请求被处理成功(每成功一次一条) | — | 约束与说明: - 事件名与属性名只能包含字母、数字与下划线,且都以字母开头。**属性值**统一不超过 `TELEMETRY_MAX_PROPERTY_VALUE_LENGTH`(100 字符),**布尔属性用原生 JSON 布尔**(不写成 `'true'` / `'false'` 字符串——那是「下游只收字符串」留下的习惯,见 §6)。这些在 `telemetry.ts` 的类型里就卡住,不指望下游报错。 - **前缀约定。** 下游普遍把 `$` 开头的名字留给自己(保留属性、内部事件),部分系统另外保留 `_`。所以契约里的事件名与属性名不使用这两个前缀——这条约束是「不撞车」,不是「不合法」,因此它写在事件目录的注释里而不是 schema 里:**将来真的撞上了,解法是在适配器里改名,而不是改契约里的事件名**(改契约等于改历史数据的含义)。 - `protocol_conversion_used` 只发「发生了转换」与两侧协议。协议是产品能力维度(三值枚举),不是供应商标识。 - `failover_happened` 的 `attempts` 分桶,不发精确次数:精确值对产品决策没有额外信息量,却是更细的行为指纹。 - **`workflow_node_executed` 的 `node_kind` 是闭集。** 它复用 `packages/contracts/source/router/types.ts` 的 `WORKFLOW_NODE_KINDS`(一个运行期数组,不是又写一份字面量联合),所以工作流引擎新增一种节点时,统计侧要么跟着认它、要么在改契约时一起决定,不会悄悄地开始丢事件。 - **`request_completed` 是唯一没有属性的业务事件,而且必须是。** 它要回答「一共处理了多少任务」,而这个数**只能由事件次数表达**:属性里没有位置能放数字,自由文本又被禁(§3)。每成功转发一次发一条,失败与被取消的请求不发(那是 `failover_happened` / `service_start_failed` 之类的事件该答的)。它也不带耗时 / Token / 状态码——那些是用户的业务数据,属于本地观测(§15)。 - **上报不读本地库。** `analytics-store` 是控制台图表的取数层,与上报无关;`request_completed` 在代理收尾处直接发,不经过请求日志(那是用户可关的调试功能,与统计的开关、保留期、字段要求都不一样,见 §5.4)。 - **主语是「被测的那个目标」,不是「这一次测试」。** 界面上的「测试全部模型」把每个「供应商 × 模型 × 协议」各做成一条服务端队列任务(`POST /api/tasks/enqueue` 一批下发),`provider_tested` 是在每条任务收尾处发一条,`result` 说的是这一个目标的成败。所以一次「全测」会产出多条事件,而不是一条汇总——同一批里既有 `success` 也有 `failed` 是正常现象,「测试了几次」只能按事件条数读,不能按用户点了几次按钮读。 - **计数口径:一件事发生几次就发几条,不问「涉及几个对象」。** `model_created` 一条端点上一次(同一模型的三个协议写三条,导入供应商包时同样按端点逐条),`provider_tested` 一个被测目标一条,`workflow_node_executed` 一个节点一条,`request_completed` 一次成功转发一条。需要「涉及了几个」时看事件条数,不额外加数字属性(§3)。 - **只有用户发起的动作才计数。** 内部发起的执行(路由求解跑默认图、诊断里的连接测试请求)走的是同一条代理链路,但它们不是「用户用了一次功能」,所以带 `origin: 'internal'` 的请求不发 `request_completed` / `protocol_conversion_used` / `failover_happened`。例外是 `workflow_node_executed`:节点确实被执行了,无论谁触发的都算。 - **「执行过」是字面意思,没跑过的步骤不算。** 路由轨迹(`WorkflowTrace`)为了给界面还原整个过程,会把两类**没有执行**的步骤也留在里面:被条件判否而跳过的节点,以及「上游缺输入」时补的占位步骤。它们带 `executed: false`,上报侧在发之前就把它们滤掉——否则一个节点被跳过会被记成「用过一次」,节点统计就同时混进了「跑过」和「没跑」。这条过滤放在 `@server/telemetry` 里,因为那是唯一知道「事件口径」的一层:引擎只管把过程如实留下来。 - **导入供应商包与手工新建是同一个口径。** 导入是核心自己完成的写入,界面上看不到「新建 Provider」那个表单,但它确实创建了记录,所以走 `import-provider-bundle.ts` 上报:这条供应商是新建的才发 `provider_created`,它的每个端点各发一条 `model_created`。反过来说,导入时按名命中既有供应商(覆盖配置)不会发 `provider_created`——那条记录原本就在,这不是「用户又建了一个」。 - **`rewrite_rule_created` 的 `builtin` 是能被触发的。** 规则的来源在库里是三档(`user` / `builtin` / `imported`),上报只把它收成两档(内建算 `builtin`,其余算 `custom`)。界面从模板起手的草稿(以及它的副本)在创建时就把 `source` 写成 `builtin`,所以这个取值不是「理论上存在、实际发不出」的装饰:核心只做映射,不去猜来源,来源由知道用户是从哪儿起手的那一层写下。 - **没有产生结果的动作不算「用了一次」。** 连接测试被取消、或一个候选都没跑起来(0 条结果)时,`provider_tested` 不发;`provider_created` 只在新建时发,编辑已有 Provider 不发。 - **没有信息量的产品事实不占一个事件名。** 事件目录里每一条都要能回答一个决策问题(§2),否则就该删。两个被撤掉的名字属于这一类: - `telemetry_toggled`:界面上**没有**统计开关(§4),所以「用户改写了开关」这个事实只有直接调管理接口才会发生。更关键的是,一个触发不到的埋点等于没写——它没有被任何真实路径验证过,不可能比不写更可信。真要提供开关时,那时再加这条事件。 - `logs_exported`:当前唯一的导出是运行日志(`/api/logs/export`,纯文本,**从来不带请求 / 响应正文**),所以那个 `withContent: false` 是个常量,「导出日志」这四个字里能被上报的只剩「用户点过一次下载」。真有了一种带用户内容的导出时,那时再新增一个描述它的属性。 - **`popular_models` / `provider_model` 两个名字被撤掉了。** 它们回答的是「当下什么模型热门」,做法都是把本地最常用的几个模型名带出去,差别只在形状(前者的名次写在 `first` / `second` / `third` 三个字段里,后者一个模型一条事件、模型名写在同名属性里)。两个都从未随正式版发出,所以直接删除、不留「已停用」的占位——「只增不删」防的是历史数据失去解释,而不是防改动本身。要恢复这条信号时,正确的做法也不是把名字原样搬回来,而是重新问一次「这个答案值不值得一条用户写下的字符串」(§3)。 - **已经发出去的名字只增不删。** 删掉一个已经随正式版发出过的事件名,等于历史数据里那一列失去解释;这种名字要停用就在文档里标「已停用」、代码里停止发送。反过来,**从未发出过的名字直接删干净**,不留占位——上面四个被撤掉的名字都属于这一类。判断依据不是「代码里有没有引用」,而是「正式版的埋点有没有发过它」。 - **单批上限 25 条**(`TELEMETRY_MAX_EVENTS_PER_BATCH`),请求体上限 64 KiB(`TELEMETRY_MAX_REQUEST_BYTES`)。这两个数字都是**我们自己的**选择,不是下游的限额:客户端默认批量远小于它们(见 §12),它们只是「往端点灌大包」的成本下限。Worker 对超长批次直接拒绝而不是截断——截断会静默改变统计口径。 ### 5.4 事件不是日志 `app_started` 之类的统计事件**不进运行日志**,也不从运行日志推导。运行日志服务于排障(见 [observability.md](./observability.md)),它有请求 ID、耗时、错误码;统计事件服务于产品决策,它必须匿名、必须低基数。两套东西的字段要求正好相反,混在一起会同时污染两边。 ## 6. 上报链路与下游抽象 ``` 桌面端 / 命令行 │ POST /v1/track 批量、直连、不透传用户代理 │ 报文 = 契约里的信封 + 事件(§5) ▼ 自建 Worker(唯一持有下游绑定的一层) apps/apis/source/index.ts │ 1. 严格校验 2. 补服务端事实 3. 交给下游 ▼ TelemetrySink(抽象接口,不知道下游是谁) apps/apis/source/sink.ts ▼ Cloudflare Analytics Engine 数据集(首版唯一的下游实现) apps/apis/source/sinks/analytics-engine.ts ``` ### 为什么中间要有一层 sink 因为**「我们采集了什么」与「它被存到哪里」是两件独立的事**,而它们的变更频率差了一个数量级:事件目录一年改几次,分析后端可能明年就换一个。把后者写死在转发逻辑里,等于每次换后端都要动一个「已经在收数据的线上服务」。 `sink.ts` 定义的接口只有一句: ```ts interface TelemetrySink { readonly name: string forward(events: readonly TelemetryEvent[], context: TelemetryForwardContext): Promise } ``` 三处刻意收窄,都是为了让它**换起来便宜**: - **参数里没有 `Request`、没有 `env`、没有原始报文**,只有「已经校验过的事件」与两项服务端事实(服务器时钟、来源国家)。所以一个 sink 不需要懂 HTTP、也不需要懂校验——那些是 Worker 的职责。这条边界让「写一个新 sink」变成一个可以照着 `analytics-engine.ts` 那个文件抄的小任务。 - **返回值只有两种结果**:`ok` / `not_delivered`。上一版有三种(`ok` / `unreachable` / `rejected` + 状态码),因为下游是一个 HTTP 服务、它有自己的回应可读;这一版的下游是一次本地调用(§7.3),**没有对方的状态码可读**,所以词汇表跟着缩了。 - **永不抛异常**。抛出去会把 Worker 变成 500(看起来像我们的 bug),而这一层能说的是「没交出去」。 于是「换后端要改哪里」有了一个可复核的答案:**只写一个新的 `sinks/*.ts`,再改 `index.ts` 里的 `createSink` 一处。** 收报文、校验、响应码这些与下游无关的代码一行都不动,它们的测试也不动——`index.test.ts` 刻意不认识下游,断言只到「写了几次数据点」(见 §14)。 > 这条承诺在 2026-10 被真的兑现了一次:从 Aptabase 换到 Analytics Engine,`index.ts` 里动到的只有 `createSink`、`TelemetryEnv` 与那一档 502 日志,路由与校验一行未改。 ### 三条设计决定 1. **客户端只连自己的域名。** 用户能审计的网络行为是「往哪里发、发了什么」,这两件事完全由我们控制。下游的接入方式只存在于 Worker 侧(上一版是一个 secret,这一版是 `wrangler.toml` 里的一段绑定),不进二进制、不进设置、不进日志。 2. **端点恒定,后端可换。** 客户端里写死的只有一个自有域名,其余全在服务端:换分析后端、换存储方案、换数据驻留区域、甚至整站迁移,客户端一行都不用改。这一条比看上去重要得多——已经装在用户机器上的旧版本没有第二次机会,**发布出去的地址就是永久地址**。直连第三方分析服务做不到这一点:`google-analytics.com` 或某个厂商的采集域名一旦写进二进制,任何后端调整都只能指望用户升级,而愿意升级的用户恰恰是最不需要被照顾的那批。 3. **首版只挂一个下游,但接口不是为一个下游设计的。** 只有一个实现时最容易想到的是「把它写死」,而这里偏偏没有:`sink` 的边界、三种结果、`name` 字段,都是按「将来会有第二个」准备的。多挂一个下游时,Worker 只是多循环一次。 关于第 2 条的落地约束: - 端点域名必须是**自有域名**,不能是平台给的子域:前者可以随时换绑、可以挂 WAF 与限流规则、可以按需切换背后的 Worker;后者绑定在平台命名空间上,改名即作废。 - 服务端对客户端承诺的是**请求格式**,不是数据去向。 - 因此客户端**不感知下游是谁**:没有项目 token、没有厂商特有字段、没有 `client_id` / `user_properties` 这些名字。`installId` 只是「本次上报的匿名安装标识」,至于它在下游被当 `index1`(采样键)用,是适配器的事。 ## 7. Worker 的职责 三件事(见 `apps/apis/source/index.ts` 的文件头),一件都不能省: | 职责 | 做法 | 原因 | |------|------|------| | 严格校验 | 用 `telemetry.ts` 的 schema 解析,拒绝多余字段 | 客户端误传敏感字段时,服务端是最后一道闸门 | | 补服务端事实 | 服务器时钟;来源国家(`CF-IPCountry`) | 只有这一层看得到 HTTP,而这两件事客户端说不准 | | 交给下游 | 全部转发逻辑在 `sinks/` | 职责可替换,见 §6 | **限流不在这份清单里,这不是遗漏**:它的位置是域名,理由见 §7.1。 **没有「白名单削平」这一步。** 契约的 `.strict()` 已经保证报文里只有契约里的字段,所以「挑认识的字段转发」是多余的;适配器直接扫描事件的键即可(§7.3)。这是 §5.2 那个原则的直接结果:契约足够严时,转发层不需要再筛一遍。 **两件服务端事实都还有去处。** 服务器时钟不再需要写了(数据点自带运行时的 `timestamp`,§7.3),来源国家仍然写进 `country` 列。第一件消失不是退步:它换成了一个客户端碰不到的戳。 ### 7.1 为什么 Worker 里没有限流 **限流只做在域名上**(Cloudflare 的 Rate limiting rules,配置说明在 `wrangler.toml`)。Worker 内部的内存计数器做不到它看起来在做的事,所以这里没有它: - 计数器住在**单个 isolate 的内存**里,而 Cloudflare 会同时跑很多个 isolate、很多个机房,状态既不共享也不持久; - 于是实际放行量是「配置值 × 机房数」的量级,**一个会随部署规模自行放大的上限**——它不像一个上限,更像一个噪声源; - 而「按客户端聚合」才是限流真正要的东西,只有边缘做得到,因为只有那里看得到同一个客户端的全部请求。 `POST /v1/track` 本身是幂等追加、无鉴权、无副作用的,漏挡不构成风险;能挡量的是那条域名规则,**而且它是这条链路上唯一的手段**——所以它不是补充项,是必需项(这也是端点必须挂自有域名而不是平台子域的原因之一)。 这条链路还有一个直接的隐私收益:**客户端的地址在这里连读都没有被读过。** 若在 Worker 里按客户端聚合,它至少要被哈希一次才成为限流键;现在 `CF-Connecting-IP` 从入口到 `sinks/` 没有任何一行代码去碰它,日志里同样没有。地区那一栏走的是另一个头(`CF-IPCountry`),而 Cloudflare 在那枚头里给的是**结论**,见 §7.3。 ### 7.2 只有一条路径 `POST /v1/track`,其余一律 404(`/` 也是)。刻意不给部署流水线留一个健康检查接口:这块域名上「只有一条路径」本身就是最强的信号,而部署之后真正要确认的只有「路由注册上了没有」——对 `GET /v1/track` 期待 **405** 就回答了它,那是一个只可能来自本 Worker 的答案(部署后手动 `curl -i` 一次即可)。一条只接受写入的接口没有安全的匿名读探针,与其为此开一个接口,不如让「方法不对」来证明「路径对了」。根路径也不占:它是这块域名上唯一「一看就知道该留给别的东西」的位置。 另外: - **不做客户端鉴权。** 桌面应用内嵌的任何 token 都能被解出来,加了只提供虚假的安全感。防护靠「域名上的限流规则 + 契约的严格校验」,两者都不在客户端的掌握里。- **不接受客户端声明的地区**:客户端报文里根本没有这个字段,请求体里若出现 `country` 会被严格校验直接打回。位置是**服务端知道的那个事实**(Cloudflare 在边缘算出的来源国家),不是客户端自述。 - **带字符集前缀的 `Content-Type` 才算 JSON。** `application/json; charset=utf-8` 接受,`text/plain` 一律 415。 - **同一批必须来自同一台设备**(`installId` / `os` / `locale` 三者一致),否则回 400 `mixed_batch`。一批在语义上就是「一个安装的一次上报」,混着发必然要把某些事件归到别的安装上去,那比拒收更糟,因为它静默地坏了口径。`version` / `arch` / `runtime` **刻意不比对**:升级与桌面/命令行切换都会让同一批里合法地出现不同的值。 - **请求格式版本化,服务端长期保留旧解析。** 端点不变意味着会有很旧的客户端一直打过来,所以新增字段必须可选,破坏性变更开新路径(`/v2/track`)并保留旧路径。这与「自有域名不可变」是同一件事的两面:既然后端可以换,老客户端就永远不会下线,那么兼容窗口就不是「支持几个版本」而是「永久」。 ### 7.3 首版适配器:Cloudflare Analytics Engine `apps/apis/source/sinks/analytics-engine.ts` 是**整条链路上唯一知道 Analytics Engine 存在的地方**(除了 `wrangler.toml` 里那一段绑定)。它做的事比上一版少一半:把一批事件摊成若干个数据点,逐个交给 `env.TELEMETRY.writeDataPoint()`。 #### 为什么是绑定而不是一个 HTTP 服务 下游是**同一个 Cloudflare 账号里的一个数据集**,写入是一次本地调用。这个差别不只是「少一次网络往返」,它把整条链路的失败词汇都改掉了: | 上一版有的 | 这一版 | | --- | --- | | 入口地址(区域常量) | 没有地址;绑定名写在 `wrangler.toml` | | 凭证(`APTABASE_APP_KEY`) | **没有凭证**,它就是这个账号里的一个数据集 | | 转发超时(3 秒) | 没有等待,调用立刻返回,运行时在后台落盘 | | 「对方回了 400 / 404」 | 没有对方的状态码,也就没有这种失败 | | 「下游把你锁了」 | **结构上不存在**:这不是一个第三方账号 | 最后一行才是这次改动的真正原因。旧下游被锁的现场是 `Owner account is locked.`——用量超出免费额度之后**整个账号被停掉、所有事件静默丢弃**,而我们的代码只能把它记成一条 `upstream_rejected`。本文件 §8.2 把「需要『统计不依赖第三方账号』的可控性」列为自建存储的第三个触发条件,那个条件在 2026-10 被一封拒信替我们满足了。 换成绑定的直接后果是**没有秘密了**:`wrangler.toml` 里不再有 `[secrets]`,也就没有「部署时忘了 `wrangler secret put`」这一类事故。仍然会有一档 500,但它现在只意味着「绑定那一段没写、或写到了别的 Worker」: ``` [apis] status=500 error=not_configured missing=TELEMETRY ``` #### 数据点的摆法:位置即列名 一个数据点由三种数组组成:`blobs`(字符串,用来分组与过滤)、`doubles`(数字)、`indexes`(采样键,**只允许一个**)。**数组是按位置读的**,所以下表就是这一层的全部约定——列序在这里定,读侧的 SQL 只负责把 `blobN` 别名回这些名字: | # | 列 | 取值来源 | SQL 别名 | | --- | --- | --- | --- | | 1 | `event` | 契约的 `name` | `event` | | 2–6 | `version` / `os` / `arch` / `locale` / `runtime` | 契约的信封字段,同名 | 同名 | | 7 | `country` | `CF-IPCountry`(**唯一一项不来自契约的值**) | `country` | | 8–17 | `reason` / `skipped` / `mode` / `kind` / `protocol` / `result` / `from` / `to` / `attempts` / `node_kind` | 契约的事件属性,同名 | 见下 | 一共 **17 列**,上限是 20。两处与契约的名字对不上,都写在 `ANALYTICS_ENGINE_BLOB_SLOTS` 里: - `name` → 列名 `event`:`name` 在 SQL 里太含糊,而这一列回答的正是「哪条事件」; - `from` / `to` → 别名 `protocol_from` / `protocol_to`:**它们是 SQL 保留字**,直接 `AS from` 是语法错误。契约里的属性名不动(那是发到线上的词表),改的是读侧的别名。 几处值得记下的: - **属性用固定列,不用一坨 JSON。** JSON 那一版看着更省事、还能自动跟着契约长,但每一处查询都要写成 `JSONExtractString(blob8, 'reason')`,而且「这个值到底是不是字符串」要交给 SQL 方言去回答(布尔在 JSON 里的读回行为各实现不一)。那个「自动跟进」用**测试**补回来了,而且更严:适配器的测试从 `TelemetryEventSchema` 里机械枚举出全部属性名,逐个要求这里有对应的一列。所以「加了事件属性、忘了加列」是一条红的用例,而不是线上一个静默的空列。 - **值一律写成字符串。** 契约里的属性只有字符串与布尔两种,而 blob 只收字符串,所以 `true` / `false` 被写成 `'true'` / `'false'`。这是这一层要做的加工:JSON 里的布尔在 SQL 侧怎么读回来要看方言,而 `'true'` 是一个所有读者都同意的值。 - **缺的值写成空串,不是省略。** blob 按位置读,少写一个会让它后面的每一列都错位一格——那是一次静默的口径错误,比缺一个值糟得多。读侧因此要把 `''` 当缺失处理,**尤其 `country`,空串不是某个国家**。 - **`doubles` 是空的,这不是遗漏。** 这份契约里没有任何数字属性(属性值只能是枚举或布尔,见 §5.3),所有指标都是计数:一次请求发了 12 条事件,读回来就是 12 行。 - **属性共用语义相同的列。** `kind` 同时装 `provider_created` 与 `rewrite_rule_created` 的 `kind`(两者就是同一套词表),`protocol` 与 `protocol_from` / `protocol_to` 都装协议枚举。于是「按 kind 分组」这类查询天然跨事件成立,不必写 `UNION`。 - **`installId` 不进 blob,它是 index。** 也**不给 `occurredAt` 留列**,理由见下面「时间」。 - **一批写几个数据点,不是一个请求。** 上一版的「一批 = 一个数组、发一次 POST」在这里变成「一批 = 若干个数据点」,逐条写、不拆不并。下游的硬上限是**一次 Worker 调用 250 个数据点**,而契约的单批上限是 25,所以永远撞不到;这条关系有测试钉着。 #### 时间:用 Analytics Engine 自己那个戳 每个数据点被写入时,运行时自动补一个 `timestamp` 列,**它就是服务器时钟**。上一版要自己处理这件事:客户端的 `occurredAt` 可能太旧(休眠两天后补报)或落在未来(系统时间不对),于是适配器里有一段「超出窗口就改用接收时间顶上」的逻辑,还有一个 23 小时的常量在对齐下游的规矩。 这一版**把客户端的时间戳丢掉了,不落任何一列**。理由是服务器那个戳比它可信得多:`occurredAt` 来自客户端,改系统时间就能伪造,也能因为休眠而偏离几个小时;`timestamp` 是运行时在写入那一刻盖的,客户端碰不到。 代价说清楚:**「事件发生」与「事件被收到」之间的差被抹掉了**。这个差有上界——客户端最多攒 12 条或等 30 秒就先发出去(§12),所以日粒度、小时粒度的趋势都不受影响,而秒级的「事件什么时候发生」这一版回答不了。要判断客户端的钟偏得有多离谱,得另开一条诊断事件,而不是把这一列留着。 起点也因此变了:**这张表里的历史从第一次部署这一版开始**,旧下游的数据不迁移(它本来也已经被锁住了)。 #### 采样与 `_sample_interval` Analytics Engine 在写入量很大时按 **index** 采样,并把采样率写在 `_sample_interval` 列里。这里的 index 是**安装标识**,也就是官方推荐的那种选法(「按用户选 index」):量大的那一个安装会被采样,其余安装的数据保持完整。 于是有一个**每个查询都要记住的规矩**: | 想要的 | 不要写 | 要写 | | --- | --- | --- | | 事件条数 | `COUNT()` | `SUM(_sample_interval)` | | 去重安装数 | `COUNT(DISTINCT index1)` | `SUM(_sample_interval)` 上再聚合,或 `uniq` | index 的长度上限是 **96 字节**,而契约保证 `installId` 是标准 UUID——36 个 ASCII 字符。`indexes` 虽然是个数组,但**只能给一个**:给多个同样会静默丢掉整条数据点。所以这里是 `[event.installId]`,一个元素。 #### 匿名:下游里没有「人」这个实体 Analytics Engine 只存数据点——没有 person profile、没有 identification、没有 feature flag。所以「不做用户画像」在这里不是一个需要声明关掉的开关,而是**下游本来就没有这个能力**。这不是省钱技巧,而是 §4 那句「标识可关联,但我们没有把它物化成一个『人』」的最直接的执行结果。 代价说清楚: - **得到**:按任意列做趋势 / 分组 / SQL 交叉——我们全部的问题都在这类里; - **失去**:用户属性、cohort、生命周期洞察、按用户筛选——这些概念在下游不存在,也就无所谓失去。 index 用的是**不轮换的安装标识**(§4):去重安装数就是 index 的去重计数,跨天的活跃与留存现成可用。 #### 地理位置 Analytics Engine 的数据点里**没有任何字段能收一个 IP 或一个国家**:它不知道自己收到了什么来源。所以地区仍然走**列 `country`**:Worker 把 Cloudflare 在边缘按真实客户端 IP 判好的 `CF-IPCountry`(一枚两字母代码)交给 sink,sink 把它落成第 7 列。 **给的是结论,不是原料。** 用 `CF-IPCountry` 而不是把客户端 IP 转发下去,有两个后果,都是想要的: - **客户端的地址从头到尾没有被读过。** 它既不被哈希(限流已经不在这层做,§7.1),也不被转发。报文的全部内容就是契约里的枚举值加一枚国家代码; - 于是 Cloudflare 自带的地区视图(按请求来源 IP 判出来的那个,也就是**机房所在地**)**必须忽略**,地区只看 `blob7`。这是这项取舍唯一要记住的地方。 拿不到时这一列是空串,而不是编一个——「不知道在哪」比「定到一个错的地方」错得少。**「拿不到」有好几种输入,全都被当成空串**:头缺失、`XX`(Cloudflare 表示无法判定)、不是两个字母(`DEU`)、以及是字母但不是国家代码的两字母串。唯一会被规范化的是大小写:`de` → `DE`。 > `country` 是**唯一一项不来自契约的值**。契约里根本没有这个字段,客户端也无从提供——它要么由这一层写进去,要么这一列永远是空的。 ### 7.4 一个必须记住的静默失败 **`writeDataPoint()` 不回答任何东西。** 它立刻返回、运行时在后台落盘;数据点不合法(列太多、index 超长、blob 总量超限)时它既不抛也不回,**那条数据点就是不在了**。所以: - 我们能承诺的只有「这次调用交出去了」,**不是「这一行入库了」**。`ok: true` 读作「已交出」,不是「已入库」; - **验收要看数据集的 SQL 查询结果**(§10),不是端点的 204; - 适配器里那个 `try/catch` 只可能包住「本地调用直接抛错」这一种情形,它挡不住静默丢——**谁都挡不住**。而且此时前面几条可能已经写出去了:`not_delivered` 说的是「这一批没有全部交出」,不是「一条都没有」。 上一版的静默失败也有一个同源的:那边的下游回 2xx 只说明它收下请求,分批摄取会逐条校验、不合格的静默滤掉而整批仍回 200。两版都得记住同一句话——**状态码不是验收标准,查询结果才是**。 > 除了这一档失败,客户端的行为**一个字都没有变**:地址不变、报文不变、批量与超时不变、非 2xx 一律丢弃不变。变的是下游与它的失败词汇,而这两样客户端本来就看不见。 ## 8. 数据存哪与保留期 **首版只有下游一份,我们不自己存副本。** 三个原因: 1. 七个视图下游开箱即用(§2); 2. 自建那一层的全部价值是「回答下游答不了的问题」,而首版还没有这类问题; 3. 不存就不会丢,也不用维护一份带标识的数据。 ### 8.1 保留期 **保留 3 个月,这是 Analytics Engine 的硬限制、不是我们能配的。** 上一版的下游最长可以设更久,所以这是一处实实在在的退步,而不是等价替换。它带来的两个后果要接受: 1. **同比与长周期报表做不了。** 「今年这个月比去年同月」这类问题在这张表上无解。对首版那七个视图(§2)没影响——它们都只看近期趋势; 2. **数据要早导出,不能等着「以后再说」。** 超过三个月的行是真没了,不是归档在什么地方。要更长历史只有一条路:定期把数据导到 R2(免费档 10 GB,我们这个体积约 70 MB/月,见 `sinks/analytics-engine.ts` 的文件头)。**这一版没做**——它属于读侧,见 §8.3。 同时得说清楚**新下游不动数据的代价比上一版小得多**:换后端不影响客户端(§6 第 2 条),而这一版连密钥都没有了。 ### 8.2 什么时候才加自建存储 出现下面任意一条时,加一层自有的汇总存储: - 需要超出下游保留期的固定报表(**现在多了一条现成的理由**:3 个月不是可以调长的设置); - 需要下游答不了的口径(超大基数去重、需要跨数据集联查); - ~~需要「统计不依赖第三方账号」的可控性~~ ——**这一条已经在 2026-10 被满足**:换到 Analytics Engine 就是因为被第三方锁过一次(§7.3)。剩下的两条是真正待触发的。 **加它的成本已经预付了**:客户端一行都不用改(§6 第 2 条),而 Worker 侧就是**再写一个 sink**(或者在同一批事件上多挂一个下游)——收报文、校验全都不动。这是 §6 那层抽象第一次真正派上用场的地方,而且已经被兑现过一次(从 Aptabase 换到 Analytics Engine)。 ### 8.3 明确不做的事 - **不做原始事件归档**(R2 / 对象存储)。它是 §8.2 第一条真正触发时的产物。到那时正确的做法是定期导 R2,而不是把每条事件再存一份; - **不建汇总表、不写查询脚本**:它们是 §8.2 那种需求出现之后的产物,不是现在的产物。 > ⚠️ 与上一版相比,§8.3 这两条**更有张力**了:上一版说「不存也不会丢」(保留期可以调到永久),而现在的保留期是 3 个月、不能调。所以「不归档」从一个便宜的取舍变成了一个有明确代价的选择——将来要做长周期报表时,第一步就是打破它。 ## 9. 端点的对外承诺 梳理一遍「谁承诺了什么」,因为这是最容易在几次重构之后变模糊的地方: | | 承诺 | 不承诺 | | --- | --- | --- | | 域名 | 永久 | 背后的实现 | | 路径 | `/v1/track` 长期保留 | 是否只有一个(将来可能有 `/v2/track`) | | 报文 | 契约(§5)的字段与枚举 | 字段落在下游的哪里 | | 响应 | 状态码与 `error` 取值(下表) | 具体是哪家下游、它的错误码 | 响应一览(都在 `index.ts` 里,是客户端唯一会读到的结构): | 状态 | `error` | 含义 | 客户端该做什么 | | --- | --- | --- | --- | | 204 | — | 交给下游了 | 丢弃这一批 | | 400 | `invalid_json` / `invalid_payload` / `mixed_batch` | 报文不对 | 丢弃;`invalid_payload` 带 `issues`(前 5 条)供开发者自查 | | 404 | `not_found` | 路径不对 | 丢弃(打错的地址) | | 405 | `method_not_allowed` | 方法不对 | 丢弃(我们会的方法只有 POST) | | 413 | `payload_too_large` | 超过 64 KiB | 丢弃或减小批量 | | 415 | `unsupported_media_type` | 不是 JSON | 丢弃 | | 500 | `not_configured` | 服务端没配下游绑定 | 丢弃并**不上报**(这是我们的问题,不该进客户端的错误提示) | | 502 | `not_delivered` | 写入没交出去 | 丢弃这一批 | **204 的读法在这一版变了。** 上一版的 204 是「下游收下了请求」,而这一版的是「数据点已交出」——`writeDataPoint()` 是一次本地调用,**它不回答任何东西**(§7.4)。所以 204 更弱了,验收只能看数据集的查询结果(§10)。 **客户端对所有非 2xx 的处理都一样:丢掉这一批,不重试、不弹窗。** 上表最后一列是给排障的人看的,不是给客户端分支用的——为统计流量写一套错误处理,得到的是用户机器上的额外痕迹(§12)。 ## 10. 查询与分析 **首版只有写入侧,没有读侧。** `apps/apis/` 只管把事件写进数据集,查询要走 Cloudflare 后台(Analytics Engine → `osw_telemetry` → SQL 查询)或 SQL API。不自建看板、不自建查询接口、控制台里也没有分析页——**写侧先落地、先验证数据真的进得去**。 这意味着 §2 那七个视图**现在得自己写 SQL**,而不是“现成报表里就有”。查询时要记住两件事: - **`SUM(_sample_interval)`,不要 `COUNT()`**——采样会让后者算少(§7.3); - **`GROUP BY` 时按列别名读写**:`event` / `version` / `os` / `arch` / `locale` / `runtime` / `country` / `reason` / `skipped` / `mode` / `kind` / `protocol` / `result` / `protocol_from` / `protocol_to` / `attempts` / `node_kind`(共 17 列)。`from` / `to` 的别名带 `protocol_` 前缀,因为它们本身是 SQL 保留字。 **不自建看板**:先把写侧与查询口径固定下来,界面等需求。真要出固定看板,那属于 §8.2 的自建存储需求——先有需求,再有界面。 **也不自建查询接口。** 到写侧稳定之后,如果发现「每次都要开后台写 SQL」太贵,那时再设计一个只读接口与控制台页面,凭证与限流一起设计。 本地开发想验证埋点是否到达,看一眼 SQL 查询结果里有没有东西,注意 §7.4 那条静默失败:204 不等于入库。排查顺序是「先看契约层的长度与枚举约束(`telemetry.ts`)→ 再看 Worker 报回的 `invalid_payload.issues` → 最后才怀疑上游是否真的落盘」。 ## 11. 分析口径 七个视图的取法。**全部按列分组,没有一项需要事先注册**——这是纯事件模型带来的直接好处(只有事件,没有可以挂上画像的对象): | 视图 | 指标 | 分组依据 | |------|------|---------| | 用户趋势 | 去重 `installId` | 日期 | | 地区分布 | 去重 `installId` | 列 `country` | | 版本分布 | 去重 `installId` | 列 `version` | | 语言分布 | 去重 `installId` | 列 `locale` | | 平台构成 | 去重 `installId` | 列 `os` + `arch` | | 功能使用排行 | 事件数 | 列 `event` | | 使用强度 | 事件数 | 列 `event` = `request_completed` | 口径说明: - **「用户数」就是去重安装数。** `installId` 就是 index1(§4),去掉重得到的就是「这段时间内有几个安装上报过」。报表上它可能仍叫「用户」,语义上不要读成自然人; - **版本与架构是事件列,不是「用户属性」。** 所以它们读的是**那一刻的值**,而不是「最后一次的值」——「一个安装什么时候换的版本」在首版就是可回答的; - **使用强度就是一个事件计数。** `request_completed` 每成功处理一次发一条,所以「一共处理了多少任务」就是 `event = 'request_completed'` 的行数(按安装看就是每个 `index1` 的计数)。它不装在任何一个属性里,所以也不存在「把 `count` 相加」这种读法; - **绝不用安装标识做分组维度**:它是超高基数,而这里是它的本职位置(index); - **事件计数不做去重**,它对应 §5.3 目录里每一行的触发次数。计数一律 `SUM(_sample_interval)`; - **跨天的留存与活跃现成可用**:`installId` 不轮换(§4); - **不做跨视图的交叉**(例如「某版本在某地区的人数」):那本来就要写 SQL,而这一版没有查询接口,所以它是「能写但还没做」,不是「做不到」。 ## 12. 运行时行为 ### 队列与批量 - 事件先入**内存队列**,按 `批量条数`(`TELEMETRY_DEFAULT_BATCH_SIZE`)或 `间隔`(`TELEMETRY_FLUSH_INTERVAL_MILLISECONDS`)触发发送,先到先发; - 内存队列**不落盘**:进程退出时未发出的统计事件直接丢弃。统计不是业务数据,为它写一份磁盘队列等于给用户留一份额外痕迹; - **失败静默**:网络错误、超时、非 2xx 一律丢批次,不重试、不弹窗、不写运行日志的 `error` 级别(最多 `debug` 一行); - **超时短**:单次上报超时按秒级设置(`TELEMETRY_REQUEST_TIMEOUT_MILLISECONDS`),不参与任何重试预算;这一层没有变——链路的下游部分确实不再等任何人(§7.3),但**客户端到端点这一段仍然是一次带超时的 HTTP**; - **不阻塞启动**:上报循环在监听成功后启动;`app_started` 也走同一条队列。 - **高频事件也走同一条队列。** `request_completed` 每成功处理一次就入队一条,但它不是一次请求一个 HTTP:满批(`TELEMETRY_DEFAULT_BATCH_SIZE`)或到点才发,也就是大约每 12 次成功处理对应一次上报。代理的收尾路径只做一次入队(同步、不等待网络),上报的延迟不进请求耗时。 **统计允许丢失。** 上面这几条合起来的意思是:断网期间的启动不会被补报、闪退前的最后一个批次会消失、手动关闭统计后的那个事件可能发不出去。这不是缺陷:统计数据的用途是看趋势与量级,为它加上持久化队列、重试预算与补报机制,得到的是一份用户机器上的额外痕迹和一套跟业务数据同等复杂的可靠性要求,而这两样都不是它该有的。 **所以所有计数都是下界,不是账。** 这一点对 `request_completed` 尤其要说清楚:它数的是「至少成功处理了这么多次」——丢批次、退出时队列里的尾巴、关闭统计后的事件都不在内。看趋势、比量级、衡量这个工具有没有在被用,这个精度完全够;拿它对账则不够,也不是它的用途。 **批量大小受两个上限约束。** 单批不得超过 §5.3 的 25 条,请求体不得超过 64 KiB。这两个数字都在契约里(客户端能在发之前自查,两端永远是同一个值)。它们不是性能调优,而是「小批量、允许多发几次」这个取向的边界。 ### 版本号来源 core 是纯 Node 进程,拿不到渲染层的 `__APP_VERSION__`,也没有 `electron`。因此**版本号由宿主注入**,随启动参数一起传进 core,core 不自己解析版本、不读 `package.json`: - 桌面形态:主进程用 `app.getVersion()`; - 命令行形态:cli 宿主自行解析。 ### 采集与保留 - 关闭采集**只影响后续采集**,不删除已经上报的数据。要按标识删历史数据仍然是一个人工流程,而用户能拿出的凭据就是他的 `installId`(管理接口的预览里能看到它)——这也让「我的数据」有一个可指认的对应物。**这一版删起来比上一版简单**:数据在自己账号的数据集里,不再需要向第三方提工单; - 本地只有 `telemetry-id` 一个文件,删掉即等于换新设备。旧标识在数据集里的记录不再产生新行,过了保留期(3 个月,§8.1)自然过期。 ## 13. 设置与说明 `SettingsSchema` 保留两个字段: | 字段 | 类型 | 默认 | 语义 | |------|------|------|------| | `telemetryEnabled` | boolean | `true` | 是否上报匿名使用统计。默认开启,界面上不提供开关 | | `telemetryEndpoint` | string | 空 | 保留字段,仅开发档覆盖,见下 | **为什么保留 `telemetryEnabled` 却不给开关。** 界面上没有入口,是因为「是否上报」在产品里是既定行为,不该让每个用户替产品做一次决定;字段本身仍然保留,是因为它是「采集是否被允许」的唯一判据,也是唯一能立刻停掉流量、且不需要发版的开关位(管理接口 `PUT /api/settings` 可写入)。默认 `true`。 **关于端点是否可配。** 端点写死在 `telemetry.ts` 的常量里,**不暴露给用户设置**:一个「改上报地址」的输入框既是无用的自由度(用户没有别的接收端),又是一个把统计流量导向他处的口子。`telemetryEndpoint` 仅作为开发档覆盖(指向本地 Worker),正式构建下为空即用常量。 设置页**不做任何展示**:没有开关、没有说明、没有预览区。 - 统计默认开启是产品既定行为,不是一项要用户拍板的设置;多一个控件(无论开关还是提示)就是多一次「请用户替产品做决定」,而这正是本次改写要拿掉的东西; - 不展示不等于不告知:口径写在 [security-privacy.md](./security-privacy.md) 与本文里,是产品承诺的一部分;需要自查时走管理接口的预览,而不是在设置页占一个位置。 **若将来需要关闭**,没有界面入口,但字段仍在:通过管理接口写入 `telemetryEnabled: false` 后,采集立即停止,队列里压着的那一批会尽力发完——那是它们最后的机会(§12)。 这里**没有**一条「开关被改了」的事件:界面上从来没有统计开关,这条事实只能由直接调管理接口产生,触发不到的事件名没有意义(§5.3)。代价要说清楚:下游只能看到「某个安装从某天起不再出现」,而那个现象同时对应着关统计、卸载与断网。首版接受它——一条理论上的信号不值得我们留一个谁也用不上的上报通道;真要提供开关(包括为合规起见把它放回界面)时,那时再加这条事件。 界面文案(若有)会进 UI 语言目录,与 [i18n.md](../data/i18n.md) 的三层模型一致。 ## 14. 实现落点 | 落点 | 内容 | |------|------| | `packages/contracts/source/telemetry.ts` | **契约层**:信封 schema、事件名闭集、属性枚举与长度约束、批量与体积上限、端点常量。客户端与 Worker 共用的唯一一份定义;**只描述事实,不描述去向**(§5.2) | | `apps/apis/source/sink.ts` | **下游抽象**:`TelemetrySink` 接口、两种转发结果(`ok` / `not_delivered`)、`TelemetryForwardContext`。**没有 HTTP:`Fetcher` / `postJson` / 转发超时都在换后端时被删掉了**,而不是留着不用(§6) | | `apps/apis/source/sinks/analytics-engine.ts` | 首版唯一的适配器:17 列的位置表与 SQL 别名、`installId` 当 index、`country` 落列、布尔与缺失值的字符串化、数据集常量 | | `apps/apis/source/index.ts` | Worker:路由、绑定检查、严格校验、同设备检查、把结果映射成状态码与一行日志;把 `CF-IPCountry` 转成 `countryCode` 交给 sink | | `apps/apis/source/index.test.ts` | Worker 的行为测试。**刻意不认识下游**:只断言「写了几次数据点」,以及状态码与日志 | | `apps/apis/source/sinks/analytics-engine.test.ts` | 适配器的测试:与契约的机械对齐(属性 → 列)、列序与别名、值的形状(布尔/空串)、`index` 与上限、失败路径 | | `apps/apis/source/log.test.ts` | 那一行日志的形状:补充项、`null` 省略、长值截断 | | `apps/apis/wrangler.toml` | 域名绑定(自有域,见 §6)、`[observability]`、**限流规则的位置**(§7.1)、`[[analytics_engine_datasets]]` 绑定(`binding` / `dataset` 两个名字各自的读者) | | Cloudflare 后台(不在仓库里) | 数据集**不用建**(首次写入自动创建);限流规则在这里配(§7.1);查询也在这里(§10)。写侧零后台步骤 | ### 客户端侧 | 落点 | 内容 | |------|------| | `packages/contracts/source/schemas.ts` | `telemetryEnabled`(默认 `true`,无界面开关)/ `telemetryEndpoint`(默认空串,仅开发期覆盖)设置项 | | `packages/core/source/telemetry/install-id.ts` | 安装标识的读写与落盘;只在采集真正开启时才创建 | | `packages/core/source/telemetry/queue.ts` | 内存队列:容量上限、批量阈值、定时冲刷、`unref()` 定时器 | | `packages/core/source/telemetry/sender.ts` | 直连发送:`createOutboundConnector(直连配置)` + `createCoreNetworkClient()`,非 2xx 视为失败 | | `packages/core/source/telemetry/index.ts` | 生命周期:开发档短路、开关订阅(开时补建队列、关时冲刷尾部)、补信封字段、`app_started`、启动失败直发、预览 | | `packages/core/source/proxy/execution/request-finalizer-handlers.ts` | 请求级统计埋点:处理成功时发一条 `request_completed`(事件次数即处理过的任务数) | | `packages/core/source/database/analytics-store.ts` | 控制台图表的取数:延迟分布、模型统计等;**不参与上报**(§3 不再有任何自由文本字段,上报侧也不需要读本地库) | | `packages/core/source/runtime/server-runtime.ts` | 启动上报循环、停机清理、启动失败原因分类 | | `packages/core/source/management/routes/operations/telemetry.ts` | 预览接口 `/api/telemetry/preview` 与静默接受接口 `/api/telemetry/report` | | `packages/console/source/api/runtime.ts` | `telemetryApi.report`(控制台只负责把界面侧的事实转交给 core;`/telemetry/preview` 仅供 curl 自查,控制台不再包装它) | | `packages/console/source/pages/runtime-settings/page.tsx` | 设置页**不涉及统计**:既不渲染开关也不渲染说明 | 网络层的先例是 `packages/core/source/management/routes/diagnostics/outbound-proxy-test.ts`:`createOutboundConnector(直连配置)` + `createCoreNetworkClient(connector)`,用完 `destroy()`。**不要动 `coreNetworkClient` 单例**,它承载的是用户代理语义。 端点域名是 `telemetry.ts` 里的一个构建时常量,构建时就是最终值。改动它的门槛比看上去高得多:**已经发出去的客户端里的地址改不了了**(见 §6),所以上线前就该把它当作永久前缀来选。 `apps/apis/` 与客户端**没有版本耦合**:Worker 可以随时改校验规则、换后端、换存储、加汇总任务,不需要等客户端发版;反向不成立,所以 Worker 侧的每一次改动都要按「对面是最老的版本」来验证。 部署是**一条命令**(`pnpm --filter @one-switch/api deploy`,或由 Workers Builds 在 push 时自动跑)且**没有密钥要配**:存储是同一个账号里的一个数据集,靠 `wrangler.toml` 里那段绑定拿到。数据集不用在后台建——第一次写入时自动创建。所以写侧的上线条件是「把绑定写完、部署」,后台只负责两件不阻塞写入的事:限流规则(§7.1)与查询(§10)。 ## 15. 非目标 - 首版不自己存一份统计:不建原始层、不建汇总表、不做归档(§8.3),只保留下游一份; - **不做读侧**:没有查询接口、没有控制台分析页、不导 R2。写侧先落地、先验证数据真的进得去(§10); - 不自建看板与查询脚本(§10); - 不做崩溃与异常上报:它的脱敏要求、保留期、采样策略与使用统计完全不同,混在一起会同时破坏两边(要做得单独设计); - 不上报请求级指标(耗时、Token、状态码):那是用户自己的业务数据,已在本地观测里完整存在; - 不做 A/B 实验与远程开关(下游是一个只收数据点的存储,本来就没有这个能力;而这类事情也不该由统计链路来做); - 不采集任何自由文本; - 不做用户画像:下游里没有「人」这个实体(§7.3); - 不因统计失败影响任何用户可见行为。