docs(analytics): document configuration-only deployment and acceptance

Replace manual injection instructions with Compose, Docker and Helm configuration, explicit platform initialization, consent controls, privacy boundaries and verified task status.

Refs #989

Signed-off-by: 千乘妍 (Xiaoyaner) <258399167+xiaoyaner0201@users.noreply.github.com>
This commit is contained in:
千乘妍 (Xiaoyaner) 2026-10-29 23:19:46 +08:00
parent 7fb74e81b2
commit 4661d2e1aa
7 changed files with 375 additions and 503 deletions

View file

@ -105,6 +105,13 @@ PVC UID 与绑定 PV 在升级前后保持不变。四个场景分别覆盖默
Sentinel、实际 MinIO S3 连接,以及由 Kubernetes API 接受的 TLS Ingress 路由。
默认清理自己创建的 namespace;设置 `KEEP_HELM_SMOKE=true` 可保留现场用于排查。
### 可选前端动作统计
默认关闭。`web.analytics.config` 接受 JSON 字符串,`web.analytics.upstream` 接受仅部署可见的
HTTP(S) origin。支持 Umami,不需要编辑 JS 或 Nginx;先在平台创建网站并取得 UUID。
配置变更触发 Web 滚动更新,旧标签页不会自动更新。字段、授权接口、初始化、隐私及验收见
[前端动作统计接入指南](../../docs/frontend-analytics-integration.md)。
### 高可用模式
```bash

View file

@ -1,6 +1,8 @@
# skillhub 部署架构与运维
对象存储可选部署、凭证和迁移说明见[对象存储手册](object-storage.md)。
可选前端动作统计默认关闭。Docker/Compose 和 Helm 只填配置即可接入 Umami,
详见[前端动作统计接入指南](frontend-analytics-integration.md)。
### CLI 设备授权地址

View file

@ -1,80 +1,145 @@
# 前端动作统计接入指南
## 1. 先了解边界
## 1. 默认关闭,按需开启
SkillHub 页面只提供被动的 `data-analytics-id` 标签,默认不采集、不加载统计 SDK、不发送事件。可选 helper 把允许的原生交互转换成 `{ id, event }`,再交给部署方选择的 adapter。点击下载只表示下载意图,不表示下载、安装成功;提交发布也不表示发布成功。部署方负责授权、隐私合规、保留期限和平台安全。
不配置时,不加载采集模块或 SDK,也不发送统计请求。正常业务不等待统计平台。
现在 Docker、Compose、Helm 用户只需填写配置。不需要改 JS、复制文件或手写 Nginx。
第一版支持 Umami 3.4.1。其他平台需要开发并发布新的注册 adapter,不是任意远程脚本插件。
本文对应 PR-1(#1010)的手工接入示例,不是产品内置开关。仅通过 Docker/Compose 或 Helm 配置开启、默认关闭和按需加载的能力计划在 PR-2 交付,见 [OpenSpec 变更](../openspec/changes/add-configurable-frontend-analytics/proposal.md)。Issue #989 要等 PR-2 完成并验证后才关闭。精确接口、事件边界和隐私约束以[英文契约](frontend-analytics.md)为准。
点击下载只是下载意图,不是下载或安装成功。提交发布也不是发布成功。
部署方负责授权策略、平台安全、数据保留和合规。接口和事件边界见[英文契约](frontend-analytics.md)。
## 2. 选择接入路径
## 2. 复用已有 Umami
### 路径 A:复用已有统计平台
前提:SkillHub 已正常部署;Web 镜像包含本功能;有可达的 Umami 3.4.1。
先在 Umami 创建网站,拿到公开的网站 UUID。管理员密码、API token 不要放入前端配置。
确认 Web 容器能够访问平台的 HTTP(S) origin。HTTPS 证书需由镜像的系统 CA 信任。
自签名平台需先按组织规范配置受信任 CA,不要关闭证书验证。
先确认平台接收地址、公开站点标识、授权策略和 CSP。不要把平台管理密钥放到浏览器。平台不受支持时,需要部署方编写自己的 adapter;当前版本不能只填平台名字就接入任意平台。
在受保护的 `.env.release` 中填写下面两项。把示例 UUID 换成自己网站的 UUID。
JSON 保持一行。单引号用于 Compose 的 dotenv 文件,不是 JSON 的一部分。
1. 复用 [helper](examples/analytics/observe/v1/skillhub-intents.mjs),不要修改业务页面或引入厂商 SDK。
2. 参照 [Umami adapter](examples/analytics/observe/v1/umami-adapter.mjs)实现 `send(intent) -> Promise<void>` 和 `stop()`。send 必须消化同步异常和异步拒绝,不向页面抛错;stop 后不能再派发请求,已经开始的 fetch 无法撤回。
3. 由 adapter 验证平台参数、清洗最终发送字段、管理 SDK 加载和超时。不要只清洗 helper 回调后就信任 SDK 自动附加的 URL、标题或身份字段。
4. 在部署方入口中连接 helper 与 adapter,只有策略允许时才初始化:
```js
import { observeSkillhubIntents } from './skillhub-intents.mjs'
// adapter 由部署方创建;必须先确认当前授权。
const stopObserver = observeSkillhubIntents({
root: document,
onIntent: intent => adapter.send(intent),
})
function stop() { stopObserver(); adapter.stop() }
```dotenv
SKILLHUB_WEB_ANALYTICS_CONFIG='{"enabled":true,"adapter":"umami","consent":"granted","adapterConfig":{"websiteId":"4398de8e-40df-4483-9eea-ed213643525f"}}'
SKILLHUB_WEB_ANALYTICS_UPSTREAM=https://stats.example.com
```
入口交付、同源代理、授权回调和 CSP 由部署方维护。下面的 edge 仅演示 Umami 两个端点;使用其他平台时,需要自行审核并替换 adapter 和代理端点。不要把平台管理界面代理到公开采集前缀。
`granted` 表示部署方确认当前策略允许采集,不表示每位用户都点击了同意。
需要用户单独授权时,初始使用 `denied`,再接入第 5 节的公开接口。本功能不提供同意弹窗。
### 路径 B:本地自托管 Umami 演示
前提:已有能正常使用的 SkillHub Web;已安装 Docker 和 Compose;能使用示例指定的 Umami 3.4.1、Postgres 16、Nginx 1.30.6 镜像;本机 8300 和 8081 端口空闲。所有 shell 命令从仓库根目录运行。示例会创建独立演示网络和临时数据库,不用于生产持久化。请在隔离环境运行。
先检查配置,再启动:
所有命令从仓库根目录运行。正常发布配置仍需包含数据库、存储和认证等参数。
```sh
docker compose -f docs/examples/analytics/compose.yaml config
docker compose -f docs/examples/analytics/compose.yaml up -d
make validate-release-config
docker compose --env-file .env.release -f compose.release.yml config --quiet
docker compose --env-file .env.release -f compose.release.yml up -d --no-deps --force-recreate web
docker compose --env-file .env.release -f compose.release.yml logs --tail=100 web
```
默认上游是 `host.docker.internal:80`,对应 release Compose 的 `WEB_PORT` 默认 80。这里是容器访问宿主机的 Web 入口,不是后端端口,也不是 edge 自己的 8081。若 Web 实际发布在其他端口(比如 18080),先在当前终端导出,再启动。后面所有 compose 命令都要在这个终端里执行,否则重建 edge 时会回到默认的 80:
不要公开分享完整的 Compose 渲染结果,其中可能包含业务凭证。
Web 启动时自动生成同源代理,只开放 `/_skillhub-analytics/script.js` 和 `/_skillhub-analytics/api/send`。
其他采集路径返回 404,不公开 Umami 管理界面。统计 DNS 在请求时解析,不是 Web 启动前提。
统计配置错误只关闭统计。容器日志中的 `[analytics]` 提示字段,不回显原值。
原有 API/auth 配置、100 MiB 上传上限、TLS、业务缓存及权限不变。不放宽 CSP。
直接运行 Docker 时,把同样两项用 `--env-file` 传给正常的 Web 容器即可。文件使用原始
`KEY=value` 格式,JSON 外不要加 dotenv 单引号。保留原有 API、公开地址和端口配置。
不要把 API 上游改成统计平台地址。
## 3. 可选自托管组合
需要 Docker Compose、可用的镜像网络及持久磁盘。这个组合增加 Umami 和它自己的
Postgres,不增加 Nginx,也不修改 SkillHub 数据库。正式使用前配置备份和访问控制。
在 `.env.release` 追加独立随机凭证。先在本地生成,再把输出填入文件,不要提交文件。
```sh
export SKILLHUB_WEB_UPSTREAM=host.docker.internal:18080
docker compose -f docs/examples/analytics/compose.yaml up -d
openssl rand -hex 24
openssl rand -hex 32
```
原生 Linux Docker 需要 `host.docker.internal` 映射,示例 edge 已包含 `extra_hosts: ["host.docker.internal:host-gateway"]`。Docker Desktop/OrbStack 已提供这个名字。宿主机 Web 必须监听容器可达的地址;如果防火墙或仅回环监听阻止访问,先调整可达性,或填写容器可以访问的其他 `host:port`。不要填写 `http://` 或 URL 路径。
```dotenv
UMAMI_DB_PASSWORD=<第一条命令生成的值>
UMAMI_APP_SECRET=<第二条命令生成的值>
UMAMI_ADMIN_PORT=8300
```
打开 `http://localhost:8300`。首次演示登录是 `admin` / `umami`,立即改密码。示例公开的数据库口令和 APP_SECRET 也只能用于演示;生产需要独立密钥、持久化、备份和访问控制。
在 Umami 创建一个演示网站,记录网站 UUID。打开 `docs/examples/analytics/observe/v1/observer.mjs`:
1. 将全零 `website` 改为刚创建的网站 UUID。
2. 将 `enabled: false` 改为 `enabled: true`。
3. 将 `hasConsent()` 接入自己的当前授权判断。仅在隔离演示、明确允许采集时可暂改为返回 true;正式接入不能把它当作用户同意。
4. 按下一节配置 rules。示例额外的 `skill.example.open` 没有对应产品元素,不会凭空产生事件。
然后重建 edge,使单文件挂载更新可靠生效:
数据库口令使用字母和数字最简单。包含特殊字符时,另填 `UMAMI_DATABASE_URL`,对 URL
密码部分做百分号编码;Postgres 的 `UMAMI_DB_PASSWORD` 仍保留原值。
先只启动平台,此时统计配置可以留空:
```sh
docker compose -f docs/examples/analytics/compose.yaml up -d --force-recreate edge
docker compose -f docs/examples/analytics/compose.yaml exec edge nginx -t
docker compose -f docs/examples/analytics/compose.yaml exec edge nginx -T
docker compose --env-file .env.release -f compose.release.yml -f compose.analytics-umami.yml config --quiet
docker compose --env-file .env.release -f compose.release.yml -f compose.analytics-umami.yml up -d analytics-postgres umami
```
打开 `http://localhost:8081`,不要从原 Web 入口验收注入。
管理端口只绑定宿主机回环地址。远程机器需使用自己的 SSH 转发或受控管理入口。
本机打开 `http://localhost:8300`。Umami 初始登录是 `admin` / `umami`,立即修改密码。
创建网站,复制 UUID。此步骤不能省略,启动容器不会自动创建网站。
将第 2 节 JSON 的 websiteId 换成该 UUID,再填写:
注意缓存:模块按 immutable 长期缓存,浏览器会一直用第一次拿到的 `observer.mjs`。本地演示每次改完入口文件,都要在开发者工具 Network 勾选「Disable cache」后再刷新,或者用一个新的无痕窗口打开。正式交付中不能原地改文件,要发布到新的版本目录(如 `v2`)并同步注入路径,不能覆盖旧版本字节。
```dotenv
SKILLHUB_WEB_ANALYTICS_UPSTREAM=http://umami:3000
```
## 3. 配置规则和增加动作
这里是 Compose 内部服务名。然后重建正常 Web:
省略 `rules` 使用冻结的 `DEFAULT_RULES`,共 12 条:
```sh
docker compose --env-file .env.release -f compose.release.yml -f compose.analytics-umami.yml up -d --no-deps --force-recreate web
```
| ID | 规则 |
同一 Compose 网络提供 DNS。Web 在独立容器或主机时需填写真正可达的内部 origin。
不使用旧示例的 edge 或宿主机 Web 代理。统计平台初始化、改密、备份仍是运维步骤,
不能把首次部署称为无需准备的一条命令。
## 4. Helm
保留已有生产 values 和 Secret。在 values 中增加以下内容:
```yaml
web:
analytics:
config: '{"enabled":true,"adapter":"umami","consent":"granted","adapterConfig":{"websiteId":"4398de8e-40df-4483-9eea-ed213643525f"}}'
upstream: https://stats.example.com
```
```sh
helm lint --strict charts/skillhub -f values-production.yaml
helm upgrade --install skillhub ./charts/skillhub -n skillhub -f values-production.yaml
kubectl -n skillhub rollout status deployment/skillhub-web
kubectl -n skillhub logs deployment/skillhub-web --tail=100
```
deployment 名称使用 Chart 默认 release 名 `skillhub`;自定义名称时替换命令中的名称。
值进入 ConfigMap 和 Web 环境变量,checksum 改变会触发 Web 滚动更新。
Chart 不自动部署 Umami。先在已有平台创建网站,并确认 Pod 出站网络、DNS 和证书可用。
集群内 origin 使用完整服务名,例如 `http://umami.stats.svc.cluster.local:3000`,
Nginx 的运行时 DNS 不自动补 Pod 的搜索后缀。
## 5. 字段、规则和动态授权
`SKILLHUB_WEB_ANALYTICS_CONFIG` 是可选 JSON 对象,最大 64 KiB,嵌套最多 32 层。
空值等同关闭。重复 JSON 键拒绝,避免部署和浏览器解释不同。
| 字段 | 含义 |
| --- | --- |
| enabled | 布尔值,默认 false。总开关,start() 不能绕过它。 |
| adapter | 静态注册名,目前只有 umami。 |
| consent | denied 或 granted,默认 denied。 |
| rules | 可选规则对象。省略用默认 12 条;显式提供会替换;空对象不采集。 |
| adapterConfig | 适配器公开参数对象。Umami 只接受 websiteId UUID。 |
未知配置字段或不合法的平台参数关闭统计。rules 沿用纯 `resolveRules` 的 active/skipped
语义:非法容器没有 active,非法条目跳过,其他合法规则继续工作。日志不输出非法 ID
或原配置。开发者可本地检查 `resolveRules(rules).skipped`,不要发送诊断给平台。
`SKILLHUB_WEB_ANALYTICS_UPSTREAM` 只供部署代理使用,不进入浏览器配置。
只接受 HTTP(S) origin,无凭证、路径、查询、fragment、空白或末尾斜杠。
不要放入平台管理密钥。浏览器前缀由 Web 自动生成,不能配置任意脚本 URL。
默认规则保持冻结:
| ID | 类型 |
| --- | --- |
| search.query.submit | submit |
| skill.card.open | card |
@ -89,112 +154,116 @@ docker compose -f docs/examples/analytics/compose.yaml exec edge nginx -T
| skill.delete.confirm | click |
| skill.delete.cancel | click |
显式传入 rules 会替换默认值,空对象表示没有动作。要保留默认值再增加已存在的动作,先合并:
例如只统计搜索和首页导航,可在 JSON 中增加:
```js
import { DEFAULT_RULES, resolveRules } from './skillhub-intents.mjs'
const rules = { ...DEFAULT_RULES, 'navigation.home.open': 'click' }
const { active, skipped } = resolveRules(rules)
console.table(skipped) // 只在本地检查,不发送这些诊断。
```json
{"rules":{"search.query.submit":"submit","navigation.home.open":"click"}}
```
把 `active` 传给 helper 的 `rules`。也可在浏览器开发者工具 Console 中直接执行以下代码,无需编辑源码,就能查看无效项:
这是 rules 字段示意,不是完整的开启配置。要保留默认值,需显式把默认条目也写进去。
ID 匹配 `^[a-z]+(?:\.[a-z]+){2,}$`,只用静态动作类别,不拼用户或资源信息。
`click` 用于普通按钮/链接,排除表单、选择器、文本框和非按钮 INPUT;`submit` 只用于
FORM;`card` 只用于已有键盘处理的 DIV role=link。选择、文件、拖放、裁剪不支持这些规则,
不要猜成 click。`skill.detail.render` 不是自动 pageview。
```js
const { resolveRules } = await import('/_skillhub-observe/v1/skillhub-intents.mjs')
console.table(resolveRules({ 'skill.example.open': 'drop' }).skipped)
```
应看到 `id: skill.example.open`、`reason: unknown rule type`。非法 ID、非字符串规则也会跳过;非法容器返回 `id: null` 的诊断和空 active。该检查只验证配置,不能证明 DOM 元素支持某类事件。
仅支持三种规则:
- `click`:普通按钮、链接;忽略 FORM、SELECT、TEXTAREA、非按钮 INPUT(包括文件选择器)。
- `submit`:只用于 FORM。点击提交和按 Enter 都归一到原生 submit;原生校验失败可能没有事件。
- `card`:已有 Enter/Space 处理的 DIV role=link,观察 click 和对应 keydown。helper 不会替应用增加键盘行为。
不支持的选择器、拖放、文件和裁剪行为不要硬填 click。未配置的动作不采集。`skill.detail.render` 是结构标签,不是自动页面访问量。
新增产品动作时,开发者在正确的现有元素上增加静态字面量 ID,再从仓库根目录运行:
```sh
node web/scripts/analytics-manifest.mjs --write
node web/scripts/analytics-manifest.mjs
```
审核生成的 [manifest](frontend-analytics-manifest.json) 差异和原生交互测试。部署方再加一条规则即可。ID 必须匹配 `^[a-z]+(?:\.[a-z]+){2,}$`:至少三段小写字母,不含数字、下划线或资源信息。已有 ID 含义冻结,不能把用户、技能名、版本、路径拼进去。manifest 是版本清单,不是运行时封闭枚举。
## 4. 部署、子路径和 CSP
edge 仅在 UI HTML 注入一个延迟执行的 module,不改变 API、登录、下载、静态资源或 runtime-config。server 级 `client_max_body_size 100M` 保留标准 Web 的 100 MiB 请求体上限;实际业务校验仍可能拒绝上传。不要在应用代理 location 覆盖成更小值。
正式启用前检查 Nginx 含 http_sub_module、`nginx -t` 成功,并审核 `nginx -T`。HTML 输入不能仍被上游 gzip/Brotli 压缩。示例禁用 HTML 缓存和旧校验器;启用或回退都应清理 CDN/浏览器旧 HTML。不要修改 API 或 immutable 资源的缓存规则。
根路径演示的 CSP 需要允许同源外部 module 和同源连接。保留原有 CSP;nonce-only 或 strict-dynamic 策略不能只靠同源满足。需要部署方协调每响应 nonce 或已批准的 hash/SRI;做不到就不要启用。不要加 unsafe-inline、unsafe-eval 或通配符。还要保留原安全头,注意 Nginx add_header 的 location 继承规则。
`/skillhub/` 等子路径不是只改一个 URL。先按正常部署方式配置 `SKILLHUB_WEB_BASE_PATH=/skillhub/` 和匹配的同源 API base,再同时调整 UI location、无尾斜线重定向、旁路正则、模块目录/注入 src、两个 Umami 精确端点和 adapter prefix。edge 到 Web 的 proxy_pass 不带尾斜线,保留原 URI,不能再次重写。逐项配置见[英文契约的子路径映射](frontend-analytics.md#supported-sub-path-mapping)。OAuth、公网 HTTPS 和资源地址仍遵守原部署要求。
## 5. 验收:照这个顺序检查
下面命令假定根路径和默认 edge 端口。子路径时为所有采集 URL 加相同前缀;自定义端口时统一替换地址。
1. 检查模块确实由 edge 提供,不是 SPA 返回的 HTML:
```sh
curl -i http://localhost:8081/_skillhub-observe/v1/skillhub-intents.mjs
curl -i http://localhost:8081/_skillhub-observe/v1/missing.mjs
curl -I http://localhost:8081/skills
```
第一个应为 200、JavaScript MIME、nosniff 和 immutable。第二个应为非 HTML 的 404。第三个应为 200、Cache-Control: no-store,并保留原 CSP(若原部署配置了 CSP)。
2. 打开 edge 的 SkillHub 页面,在开发者工具 Network 勾选「Preserve log」和「Disable cache」,筛选 `/_skillhub-umami/`。刷新后应加载 script.js,但没有自动 pageview 或 send。
3. 搜索框输入任意本地测试文字,点击搜索。应恰好有一条 `/_skillhub-umami/api/send` POST,响应 200。再按 Enter 搜索一次,应新增一条,不是两条。切换 Network 的 Payload 查看真正出站 JSON,不要只看回调。
4. JSON 只能包含 `{ type: 'event', payload: { website, name, data: { event } } }`。website 是配置的 UUID;搜索 name 为 search.query.submit,event 为 submit。不得出现搜索词、完整 URL、标题、referrer、身份或额外 data 字段。
5. 打开 Umami 网站事件页,选择当前时间范围,应看到 search.query.submit 和对应数量。平台处理可能稍有延迟;不能把 HTTP 200 单独当作入库证明。
6. 执行下面的 Console 命令后再搜索,页面仍能使用,但 Network 不得新增 send:
当前页面挂载后提供接口,可接入已有同意组件;浏览器 Console 也可用于验收:
```js
window.skillhubAnalytics.stop()
// 仅在重新获准后:
await window.skillhubAnalytics.start()
```
stop 不能撤回已开始的 fetch。重新获准后才可调用 start();同一个 root 重复初始化保留第一次规则和回调,修改规则需要先 stop 再初始化。
start() 是当前页的显式授权,支持初始 consent=denied。重复 start 不重复安装。
stop() 幂等;等待中的模块/adapter 不能迟到后重启采集。stop 返回后不新派发请求,
但已经开始的 fetch 无法撤回。规则变更使用 stop 再初始化;环境变量变化需重建或重启 Web。
旧标签页不会自动收到新环境参数。修改 env 文件本身不会改变正在运行的容器。
7. 将入口恢复 `enabled: false`,重新创建 edge。保持「Disable cache」勾选(或换一个新的无痕窗口),刷新页面后再搜索:不得加载 script.js、产生采集回调或 send。完全不注入时也应零采集。最后在隔离演示中停止 Umami,再打开新页面搜索和打开技能卡片,应仍正常,无统计依赖导致的页面错误:
## 6. 根路径、子路径与验收
根路径无需改路由。子路径只需保持现有三项一致:
```dotenv
SKILLHUB_WEB_BASE_PATH=/skillhub/
SKILLHUB_WEB_API_BASE_URL=/skillhub
SKILLHUB_PUBLIC_BASE_URL=https://skills.example.com/skillhub
```
浏览器采集路径自动变为 `/skillhub/_skillhub-analytics/`,正常入口只重写一次。
`/_skillhub-analytics/` 是保留前缀,不能作为 Web base path。
保留当前 CSP;需要允许已批准的同源外部脚本和连接。nonce-only/strict-dynamic 部署仍需
按本身的安全策略授权,无法授权就不开启。不要增加 unsafe-inline、unsafe-eval 或通配符。
按顺序验收。下面用本机默认 Web 端口,实际环境替换 origin 和子路径:
```sh
docker compose -f docs/examples/analytics/compose.yaml stop umami
curl -i http://localhost/runtime-config.js
curl -i http://localhost/_skillhub-analytics/script.js
curl -i http://localhost/_skillhub-analytics/api/websites
```
验收后如需继续演示,使用上面的 up 命令恢复。不要为了测试停止共享平台或生产服务。
1. runtime-config 为 no-store;原 API/auth 键仍在;不得包含 upstream。
2. 开启且 granted 时 script.js 应是 JS 200;未注册路径应为 404。
3. 新开 SkillHub 页面,Network 保留日志。先看到应用挂载,再按需加载 controller、adapter
和 script.js。不会自动发送 pageview。
4. 搜索框输入本地测试文字,点击搜索,再按 Enter 搜索。每次应各有一条 api/send,页面正常跳转。
5. 检查真正出站 JSON,只允许 `{type:'event',payload:{website,name,data:{event}}}`。
搜索 name 为 search.query.submit,event 为 submit。不能有查询文字、URL、标题、身份或额外 data。
6. 在 Umami 网站事件页核对事件和数量。HTTP 200 不能单独证明入库。
7. 调用 stop,再搜索。业务继续,不能新增 send。重新 start 后允许继续。
8. 关闭配置或 denied 后重建 Web,打开新页面:不得加载 controller/adapter、script.js 或 send。
denied 页面只有显式 start 才会加载。
9. 在隔离测试环境停止 Umami,或用不可解析的内部域名启动 Web。Web 仍应就绪,搜索、
登录、卡片、下载继续遵守原业务行为。不要停止共享或生产平台来测试。
10. 根/子路径都测试正常上传(大于 1 MiB 且业务有效)、登录与下载。统计不代表业务成功。
## 6. 故障排查
## 7. 排查、升级和关闭
| 现象 | 常见原因 | 处理 |
| --- | --- | --- |
| module 404 | 未挂载模块,版本目录或子路径不一致 | 对照 nginx -T、挂载路径、注入 src 和相对 import,三份 mjs 都要可访问;不要回退到 index.html |
| MIME 报错或返回 HTML | 模块误入 SPA fallback,缺少 mjs MIME | 使用示例独立前缀、try_files 404 和 JavaScript MIME,保留 nosniff |
| CSP 拦截 | script-src/connect-src 不允许,或 nonce-only 策略 | 按原 CSP 的授权机制交付;不要删除 CSP 或放宽为通配符 |
| 上传 413 | 外层代理仍默认 1 MiB,或请求超过 100 MiB | 用 nginx -T 检查整条代理链;应用代理继承 100M,超过标准上限仍应拒绝 |
| 没有事件 | 走了原 Web 而非 edge,enabled/授权未开,UUID 错,规则未配置或宿主不支持 | 按第 5 节从模块、skipped、Network 到平台事件页逐步确认;检查时间范围;不要把结构标签当 pageview |
| edge 502 | Web 上游地址/端口错误,宿主不可达 | 核对 WEB_PORT、host-gateway、防火墙和监听地址;不要把后端或 edge 端口填成 Web 上游 |
| Umami 不可用 | SDK 404、超时、平台停止或网络故障 | 页面应继续正常;检查平台和代理日志,恢复后重新打开页面;没有重试补发保证 |
| 现象 | 检查 |
| --- | --- |
| 没有模块/事件 | 总开关、授权、容器是否重建、是否新页面;默认关闭是正常情况。 |
| 配置报错 | 看 `[analytics]` 字段诊断;JSON 用双引号,enabled 是布尔值,UUID 正确;不要发出原值。 |
| script.js 502 | Web 到平台的 DNS、网络、端口或证书;origin 不带路径;业务应仍可用。 |
| SDK 失败后无事件 | 加载失败/超时后不重试;恢复平台后新开页面,或 stop 再 start。 |
| 模块/资源返回 HTML | 检查部署版本、base path 和入口路由,不能改成任意远程脚本。 |
| CSP 拦截 | 按原策略授权,不删除 CSP 或放宽通配符。 |
| 有点击但没有统计 | 规则未配置、宿主不支持、超过 Umami 名称 50 字符,或 best-effort 丢失。 |
| 上传 413 | 检查整条外层代理链,正常 Web 仍是 100M,统计未降低它。 |
## 7. 升级、关闭和已知限制
升级先比较[manifest](frontend-analytics-manifest.json),已有 ID 含义冻结,新 ID 不自动加入默认规则。
消失的配置 ID 不再触发。SDK 固定 Umami 3.4.1;升级需重测真实 payload、stop、并发和
根/子路径业务。回滚 Web 镜像要同时使用该版本支持的配置;先关闭统计最稳妥。
升级时先比较新旧 manifest。已有 ID 语义不变;含义变化需要新 ID,重命名/删除必须有发布说明。消失的 ID 不再触发,新 ID 默认不自动加入规则。模块/SDK 升级要重新验证真实出站字段、stop 派发边界、并发发送、根/子路径和业务行为,不能只看构建成功。
持久关闭:把配置留空或 enabled 改 false,按第 2 节重新创建 Web。新页面应零模块/SDK/请求。
老标签页不自动更新;需让用户关闭/刷新,或在现有同意组件调用 stop。
紧急全局阻断可在平台或独立采集路由禁用接收,再处理旧页;不能撤回已发出的请求。
临时关闭当前页用 stop();持久关闭把入口改回 enabled:false,并按版本发布规则更新、清理 HTML 缓存和刷新。彻底关闭则撤掉注入/edge,让访问回到正常 Web 入口,确认新 HTML 不引用模块、Network 无采集。关闭平台网站或封锁采集路由是额外控制,不能代替授权撤回。
结束本地演示(会删除演示数据库,先确认无需要保留的数据):
自托管只停止统计服务,不要对整个业务栈执行 down:
```sh
docker compose -f docs/examples/analytics/compose.yaml down -v
docker compose --env-file .env.release -f compose.release.yml -f compose.analytics-umami.yml stop umami analytics-postgres
```
限制必须一起评估:
数据库卷保留。删除统计数据需另行备份、确认后只处理 `analytics_postgres_data` 对应的卷,
不要使用整个项目的 `down -v`。
- 尽力发送,无重试、无持久队列,可能少计。SDK 加载期最多在内存暂存 20 条,默认最多等三秒;超额、失败、超时或 stop 都丢弃。已就绪发送彼此独立,不因一个请求挂起而阻塞后续动作。
- 示例固定 Umami 3.4.1。stop 的派发保护依赖此版本在 fetch 前同步序列化的顺序,升级必须重跑适配器测试和真实浏览器验收;不要同时加载第二个 Umami tracker。
- JSON 字段少不等于匿名传输。IP、User-Agent、Origin 仍到平台;SDK 还带网站、实际 hostname 和后续会话 cache 头。edge 去掉采集代理的 Cookie、Authorization、Referer,并使用 no-referrer。Umami 仍可能据传输元数据推导浏览器、系统、设备、地域和会话。
- SDK 在清洗前仍会在页面内读取标题、URL、referrer 和自身 localStorage 开关;通用 helper 不读取这些内容。平台日志、数据保留和权限需另审计,详见[隐私契约](frontend-analytics.md#privacy-and-integration-modes)。
## 8. 隐私和自定义开发边界
JSON 只保留静态 ID 和原生 event。helper 不读内容、输入值、URL、Cookie、存储或身份。
SDK 在清洗前仍会读取页面标题、URL、referrer 和自己的 localStorage 开关。
少字段不等于匿名:IP、User-Agent、Origin 仍到平台;Umami 还有 website、实际 hostname、
会话 cache 请求头。Nginx 在统计上游前移除 Cookie、Authorization、Referer,并隐藏 Set-Cookie。
浏览器到 Web 本身仍可能带这些 HTTP 元数据,需按原 Web 日志和权限策略管理。
Umami 仍可能由传输元数据推导浏览器、系统、设备、地域和会话。数据库缺失 URL 的回退
是空 path/query 和 localhost,不是用户当前路径。访问日志、保留和权限要单独审核。
尽力发送,无重试或持久队列。SDK 加载期最多内存暂存 20 条,默认等三秒;超额、失败、
超时或 stop 会丢弃。就绪后的请求彼此独立。不要同时加载第二个 Umami tracker。
stop 的派发保护依赖固定版本在 fetch 前同步序列化的顺序,升级必须重新验证。
自定义 adapter 属于开发流程,不是部署流程:在 `web/src/features/analytics/adapters/`
增加参数校验与 send/stop factory,注册到静态 registry,再增加部署端校验和精确代理端点。
factory 检查取消信号后才创建 SDK。通用 controller 不认识厂商字段,业务组件只提供被动标签。
复用唯一的 `skillhub-intents.mjs`,不复制 helper,也不开放远程 JS URL。
新增标签时运行 `node web/scripts/analytics-manifest.mjs --write` 并审核语义和原生交互。
测试命令为 `pnpm -C web test src/features/analytics` 和 `python3 scripts/tests/web-analytics-test.py`。

View file

@ -1,392 +1,178 @@
# Passive frontend analytics attributes
# Passive frontend analytics contract
For step-by-step setup, see the [Chinese integration guide](frontend-analytics-integration.md).
This document is the English contract reference. Configuration-only deployment is
planned in [PR-2](../openspec/changes/add-configurable-frontend-analytics/proposal.md);
the current examples remain deployment-owned and opt-in.
SkillHub exposes semantic HTML attributes for separately maintained, optional
collectors. It does not collect or send analytics by default. There is no SDK,
vendor dependency, vendor token, network reporter, app script loader or
telemetry configuration switch. The application entrypoint and production Nginx
configuration are unchanged. Deployment owners control consent, script delivery,
retention, transport and compliance; this is not an official vendor integration
or a durable business-event feed.
For deployment, use the [Chinese configuration guide](frontend-analytics-integration.md).
Docker/Compose and Helm ship optional analytics with Web. No configuration means no
controller/adapter chunk, SDK or collection request. The small configuration gate is
part of bootstrap; the controller and SDK are not. Business components remain passive.
## Contract and scope
`data-analytics-id` names an action category (what kind of action), not a unique
event or instance ID, and describes intent, not its result. Many elements and
many events share the same category. Lowercase dotted names are independent of
language, visible text and layout.
`data-analytics-id` names an action category, not a unique event, resource or user.
It describes intent, not its result. IDs match `^[a-z]+(?:\.[a-z]+){2,}$`.
Lowercase dotted names are independent of language, visible text and layout.
Published meanings are frozen. A meaning change requires a new ID. Renames/removals
need release notes and a reviewed [manifest](frontend-analytics-manifest.json) diff.
The manifest is an AST source inventory, not a runtime closed enum or a count of
users, pageviews, successful operations or exhaustive business coverage.
The example callback payload is `{ id, event }`: `id` is the matched category;
`event` is the native DOM event type observed (`click`, `submit`, or `keydown`),
not a business result. Evolution within this module version is additive only:
new optional fields must be allowlisted static values, consumers ignore unknown
keys, and existing field meanings never change. Breaking changes use a new
versioned module path.
The inventory includes landing/search, skills, suites, publishing, review,
governance, namespaces, GitHub integration, notifications, preferences, account,
authentication, administration and navigation. Literal branches distinguish finite
actions. Never interpolate resource, slug, version, user, provider, role or backend
values into IDs. Fixed navigation metadata is source-owned. Forwarding sites do
not necessarily represent DOM elements; portals and repeated resources share IDs.
Published IDs are frozen. A meaning change needs a new ID; rename/removal must be
called out in the PR and release notes and visible in the generated manifest diff
(the CI drift test checks source against that manifest). Deployers should diff
the manifest between versions: configured IDs that disappear simply never fire;
new IDs remain inert until explicitly configured. The manifest is not a runtime
closed enum.
Literal conditional branches distinguish finite business actions (for example,
namespace freeze/unfreeze/archive/restore/delete and each confirmation/cancel).
No resource, slug, version, user, provider, role, label instance or backend string
is interpolated into attributes. Fixed navigation metadata is source-owned.
`data-analytics-event`, where present, describes a native observation boundary;
it is a hint for a deployment collector, not an application event dispatcher.
The source inventory covers these domains:
- Landing/catalog/search: queries, sorting, filters, results, pagination and links.
- Skills: detail structure, tabs/versions/comparison, documentation and files,
acquisition/copy, social actions, labels, visibility and lifecycle confirmations.
- Publishing: package/folder selection, preview/retry, submission/warnings and
avatar editing, staging, retries and cancellation.
- Suites: catalog/detail, members/versions, editor/import, publishing tasks,
lifecycle and destructive actions.
- Reviews, promotions, reports and governance: queues, detail inspection,
dispositions, layered confirmations, rejection editing, cancellation and retry.
- Namespaces: management, membership/import, invitations, ownership and lifecycle.
- GitHub: collection/settings, discovery, credentials, scheduling/admission,
repositories/sources/runs, conflicts and review inspection.
- Notifications/preferences, account/profile/security/token flows, login,
registration/reset/device/CLI authorization, administration and global navigation.
[The generated manifest](frontend-analytics-manifest.json) is the authoritative
source index: registered routes and their implementations, literal JSX sites,
conditional values, static navigation definitions and forwarding sites. Counts
are recomputed from the TypeScript AST; they are not quotas. Unique IDs are
semantic categories, JSX sites are source declarations, and forwarding sites
may have no literal ID until a caller opts in. A wrapper prop is not itself a
DOM element. Repeated resources/pages, desktop/mobile navigation and portal
instances can share an ID; neither IDs nor source sites count runtime elements,
unique users, pageviews, successful operations or exhaustive business coverage.
Development checks (from the repository root with web dependencies installed):
Run from the repository root with dependencies already installed:
```sh
node web/scripts/analytics-manifest.mjs
# After reviewing intentional source changes:
# Only after reviewing intentional source changes:
node web/scripts/analytics-manifest.mjs --write
pnpm -C web test src/app/analytics-manifest.test.ts
pnpm -C web test src/app/analytics-manifest.test.ts src/features/analytics
```
Do not ship the manifest as a runtime closed enum. A deployment collector should
have its own small, explicit event/ID allowlist and update it deliberately.
### Original eight compatibility meanings
| ID | Meaning |
| --- | --- |
| `search.query.submit` | Search form submit; button and Enter converge here. |
| `skill.card.open` | Existing card activation; click or its direct Enter/Space handler. |
| `skill.detail.render` | Structural detail root after loading/error/missing/deleted guards; not a unique pageview or success. |
| `skill.download.request` | Package download attempt; authentication/guards may reject it. |
| `skill.command.copy` | Command-copy attempt for the displayed installation method; not clipboard or installation success. |
| `skill.prompt.copy` | Agent-prompt-copy attempt; not execution or installation. |
| `skill.publish.request` | Publish/update/resubmit attempt, never avatar-only save. |
| `skill.publish.confirm` | Explicit publish warning continuation, never all generic dialogs or avatar-only save. |
| search.query.submit | Native search form submission, not typing or search success. |
| skill.card.open | Existing card click or direct Enter/Space activation. |
| skill.detail.render | Structural loaded detail root, not an automatic pageview. |
| skill.download.request | Download attempt; guards may reject it. |
| skill.command.copy | Command-copy attempt, not clipboard or installation success. |
| skill.prompt.copy | Prompt-copy attempt, not execution or installation success. |
| skill.publish.request | Publish/update/resubmit attempt, not avatar-only save. |
| skill.publish.confirm | Explicit publish warning continuation, not every dialog. |
Installation method tabs distinguish SkillHub and ClawHub; the original command
copy ID remains compatible. Public/namespace/private choices and business modes
use static categories, not data fetched from the selected object.
Installation-method tabs distinguish SkillHub and ClawHub without changing the
original command-copy meaning. Visibility choices and business modes use static
categories, not data fetched from the selected object.
## Native observation policy and limits
Attributes alone never create events. Choose the native event per host, and
count one semantic target per event rather than every marked ancestor:
Attributes alone do not create events. Keep one nearest semantic target per event.
Never change defaults, propagation, focus, history, permissions or app handlers.
- Forms: observe `submit`, not both submit-button click and submit. Enter and
native validation follow existing app behavior. Rejected validation may produce
no submit. Search typing/clear is not a search submission.
- Buttons/links: observe click, including browser-generated keyboard click. Ignore
disabled, aria-disabled and data-disabled controls. An enabled click can still
fail app guards; busy/authorization semantics remain the application's.
- Custom DIV cards with existing Enter/Space handlers need that keydown in addition
to click. Native buttons must not count both. Tabs generate click from navigation
keys; do not count Arrow/Home/End keydown again. Do not invent keyboard handlers
for existing mouse-only rows or directories.
- Native select: observe change. Where the static marker is on an option, read only
`selectedOptions[0].getAttribute('data-analytics-id')`, never option value/text.
A select marker describes its context, not the raw choice. Avoid click/change
double counting. Checkbox/radio change is an intent, not saved server state.
- Radix Select items are actual option DOM, sometimes in a portal. Pointerup or
Enter/Space on an item is selection intent, not proof that onValueChange committed
a new value. Opening the trigger and navigation/highlight are not committed
selections. A click-only collector is insufficient. Validate event order with
the deployed component/browser version rather than manufacturing app hooks.
Trigger `*.interact` markers cover observable activation/keydown intent, including
closed-trigger typeahead; never capture the typed key, infer the chosen value,
or call that interaction a committed selection. Typeahead can change selection
without opening an item. Keep trigger interaction and item selection-intent
metrics separate instead of counting both as the same selection.
- File inputs: change means a selection was offered, not upload success. Drop is
offered files, not accepted/validated files. Picker-opening click differs from
change; ignore programmatic input clicks as additional user intent. Never read
filenames, paths or file metadata. Picker cancellation is not observed.
- Crop surface pointer/keyboard interaction is adjustment intent, not proof the
crop changed or an image upload succeeded; no coordinates or image data may leave.
- Explicit cancel/close buttons, including X, preserve workflow-specific meaning.
Escape, overlay dismissal, route teardown, browser Back, automatic close and
native picker cancel are not reliably observable through these static attrs.
Closing an editor is not proof an in-flight request was aborted.
- FORM uses submit, not submit-button click plus submit. Enter converges here;
rejected native validation may produce no submit. Typing/clear is not submission.
- Ordinary buttons/links use click, including browser-generated keyboard click.
Ignore disabled, aria-disabled and data-disabled controls. App guards still apply.
- DIV role=link cards with existing Enter/Space handlers also use keydown. Do not
synthesize activation. Ignore repeat, IME and modifier keydown. Native buttons
must not count keydown and their generated click twice.
- Tabs generate click from navigation keys; do not count Arrow/Home/End again.
Do not invent keyboard support for existing mouse-only rows/directories.
- Native selects, checkboxes and radio changes have different boundaries. An
option marker can describe static selection intent, never its value/text. Do
not infer committed selection from opening a trigger or highlighting an item.
- Radix selection may use pointerup or Enter/Space in a portal. Trigger typeahead
can change selection without opening an item. Trigger interaction is not proof
of committed selection; never capture typed keys. Current finite rules do not
implement select/file/drop/crop; do not misconfigure them as click.
- File selection and dropping only offer files, not upload success. Do not read
filename/path/metadata. Programmatic picker clicks are not extra user actions;
cancellation is not observed. Crop interaction is not proof of changed pixels.
- Explicit cancel/close retains workflow meaning. Escape, overlay, route teardown,
browser Back, automatic close and native picker cancel are not reliably observed
by these markers. Closing is not proof a pending request was aborted.
Use document-level delegation for portals; React ancestry is not DOM ancestry.
Wrapper forwarding is caller-scoped, including confirmation/cancel and close X;
generic UI primitives do not guess business IDs. Keep the nearest semantic target
and reject nested unmarked interactive controls rather than attributing them to
an ancestor card/form. Synthetic clicks (including temporary download anchors)
are not new successful user actions. Do not block or change default behavior,
propagation, focus, route history, permissions or app handlers.
Use document delegation for portals; React ancestry is not DOM ancestry. Wrappers
forward caller-scoped IDs, not guessed business IDs. Reject nested unmarked
interactive controls rather than attributing them to an ancestor card/form.
Synthetic temporary download anchors do not prove new successful user actions.
SPA classification, consent transitions and view deduplication are separate
policies; never derive them from full URLs/query strings.
`skill.detail.render` is not an automatic pageview event. SPA route classification,
render retries, consent transitions, duplicate initialization and view deduplication
are separate collector policies. Never derive these from full URLs/query strings.
## Single product source and interfaces
### Privacy and integration modes
The sole helper is `web/src/features/analytics/skillhub-intents.mjs`:
The recommended output allowlist is only `{ id, event }`, each validated against
operator-owned static categories. Do not collect textContent/innerHTML, input or
option values, clipboard contents, href/full URLs/query/fragment, cookies, storage,
identity/account IDs, device codes, tokens, invitation links, private coordinates,
filenames/paths, review/report reasons or crop data. Do not scrape arbitrary DOM
attributes or reuse test IDs (some intentionally contain resource identifiers).
- `observeSkillhubIntents({ root, rules, onIntent }) -> stop`.
- Frozen `DEFAULT_RULES` has the original twelve rules listed in the guide.
- `resolveRules(rules) -> { active, skipped: [{ id, reason }] }` is pure. Invalid
containers produce id:null and no active rules. Unknown IDs are not rejected
merely for being absent from the manifest. Invalid grammar/types are skipped.
- Omitted rules use defaults. Explicit rules replace defaults; {} collects nothing.
- click excludes FORM/SELECT/TEXTAREA/non-button INPUT; submit is FORM-only; card
is DIV role=link only. Host validity is checked at observation, not resolution.
- Intent is exactly `{ id, event }`; event is click/submit/keydown. Same-version
evolution is additive optional allowlisted static fields; consumers ignore
unknown keys. Breaking changes need a new module contract.
- Importing does nothing; without callback no listeners are installed. Same root
returns the same stop and retains the FIRST rules/callback. Stop then initialize
to change configuration. Old disposers cannot remove a new installation. Stop
is idempotent; nested observers share event deduplication.
- Callback throws/rejections are swallowed without affecting business events.
The helper itself has no network, cookies, storage, identity or history hooks.
Disabled integration means no injected script and zero analytics collection.
Enabled delivery does not itself authorize collection: initialize only after
applicable consent/policy approval, stop on withdrawal, and separately review any
outbound adapter and CSP connect destinations. External JS has page privileges;
serve reviewed versioned code, restrict access and treat compromise as a security
incident. Vendor credentials and ingestion endpoints belong outside core.
The controller owns authorization, rule resolution, generations and lifecycle.
Static registry metadata validates public adapterConfig; factories lazily import
adapters. Factory cancellation prevents late imports from creating an SDK after
stop. Adapter exposes `send(intent) -> Promise<void>` (never rejects) and stop().
The loader runs separately after the first React DOM commit, never blocking mount.
Disabled, absent, invalid or denied configuration does not import the controller.
Public `window.skillhubAnalytics.start()` explicitly grants current-page consent;
stop() immediately disables observation. Enabled=false cannot be overridden by
start. Deployment changes require recreating/restarting Web; old tabs do not update.
## Configure rules and add tags
## Configuration and proxy
[The vendor-neutral helper](examples/analytics/observe/v1/skillhub-intents.mjs)
exports `observeSkillhubIntents({ root, rules, onIntent })`, frozen `DEFAULT_RULES`
(the original 12 categories), and pure `resolveRules(rules)`:
`SKILLHUB_WEB_ANALYTICS_CONFIG` is JSON with enabled (boolean/default false),
adapter (static registry name), consent (denied/granted/default denied), rules
(optional finite rule map), adapterConfig (adapter-owned public object). Umami
accepts only websiteId UUID. Invalid configuration disables only analytics with
field-only diagnostics, never raw values. Rule resolution keeps its skip behavior.
The startup parser limits JSON to 64 KiB/32 levels and rejects duplicate keys.
```js
import { DEFAULT_RULES, resolveRules, observeSkillhubIntents } from './skillhub-intents.mjs'
const rules = { ...DEFAULT_RULES, 'skill.example.open': 'click' }
const { active, skipped } = resolveRules(rules) // Inspect locally; do not send config diagnostics.
const stop = observeSkillhubIntents({ root: document, rules: active, onIntent: sendIntent })
```
`SKILLHUB_WEB_ANALYTICS_UPSTREAM` is a deploy-only HTTP(S) origin: no credentials,
path, query, fragment, whitespace or injection characters. It is never put in
browser configuration. Existing BusyBox tools validate deployment control fields;
base64 transports JSON safely as data into the existing no-store runtime-config.js.
No jq, Node runtime, extra package, CSP relaxation or HTML injection is required.
Omitting `rules` uses the defaults; passing it REPLACES them. Merge explicitly as
above. The example accepts the same grammar the product manifest enforces:
`^[a-z]+(?:\.[a-z]+){2,}$` (at least three dot-separated segments, lowercase letters
only; no digits or underscores). For example, `skill.command.skillhub.select`
can reuse `click`. Only finite rule types are supported:
The reserved same-origin prefix is `/_skillhub-analytics/`. BASE_PATH is applied in
the browser; existing Nginx rewrite strips it once. Enabled configurations generate
exact script.js and api/send routes, with method restrictions; all other paths are
404, never Umami admin. Consent-denied enabled configurations retain those routes
for explicit dynamic start, but do not load/request them automatically.
Variable proxy_pass uses the existing resolver at request time. HTTPS verifies
certificates using the system trust bundle and correct SNI. Collector proxying
strips Cookie/Authorization/Referer and hides Set-Cookie. API/auth routes, 100M
uploads, TLS, business caching and permissions are not changed.
- `click`: ordinary buttons/links. FORM, SELECT, TEXTAREA and non-button INPUT
hosts are ignored (including file inputs).
- `submit`: FORM only; button click and Enter converge on native submit.
- `card`: DIV role=link with its existing Enter/Space handler, plus click. The
observer does not implement keyboard activation or synthesize clicks.
## Privacy and integration modes
Select/file/drop/crop and other special interactions need a future rule type;
never configure them as `click`. Unknown/unconfigured actions, invalid IDs,
non-string rules and unsupported rule types/hosts are skipped without affecting
the page. `resolveRules(...).skipped` gives `{ id, reason }` for invalid entries;
an invalid rules container yields an `id: null` diagnostic and an empty active map.
It validates configuration, not the live DOM host.
Allowlist only static id/event, not text/HTML, input/option values, clipboard,
URLs/query/fragment, cookies, storage, identities, codes/tokens, private paths,
filenames, review reasons or crop data. Test IDs can contain resource identifiers;
do not scrape them. The manifest is not a payload.
Importing the helper does nothing; without a callback it installs no listeners.
The same root returns the existing stop function and keeps the FIRST config and
callback. To change config, call `stop()` and initialize again. Nested observers
from this module instance share event deduplication; only the nearest marked host
is considered, never an ancestor behind an unmarked nested interactive control.
Native-button keydown, repeated/IME/modifier card keydown are ignored. Sync throws
and returned rejected promises from the callback are swallowed, with no retry or
queue. No network, cookies, storage, identity or route patching occurs in the
helper, and it never prevents defaults or stops propagation.
Umami 3.4.1 uses manual-only script attributes and a final before-send sanitizer.
Only `{type:'event',payload:{website,name,data:{event}}}` survives; website must
match configuration, name must match product grammar and Umami's 50-character
limit. Pageview/identify/performance/context/extra data are dropped. Payload values
are read once. A non-enumerable toJSON guard rechecks stop synchronously at fetch
dispatch; upgrading the SDK must reverify its ordering with dispatch-boundary tests.
Stop cannot recall a fetch already started.
To add a normal action: product owners add a literal `data-analytics-id` on the
appropriate existing host, run `node web/scripts/analytics-manifest.mjs --write`,
review the manifest diff and test the host/native behavior. Deployers then add one
rule line. No product listener or SDK is needed. Download click is NOT download or
installation success; publish submission is NOT publish success.
At most 20 valid intents wait in memory for up to three seconds while the SDK
loads. Excess, failure, timeout or stop drops them. Once ready, requests are
independent and time-bounded, with no retry/durable queue/post-load backlog.
Do not load a second Umami tracker. Missing/blocked/offline collectors never reject
into the application. SDK loading is trusted code with page privileges: review and
pin its version, access and retention; it is not an arbitrary remote-JS plugin.
The repository-integration test lives at `web/analytics-example.test.ts`, alongside
the existing root-level web tests. Vitest discovers it with `pnpm -C web test`;
run it alone with `pnpm -C web test analytics-example.test.ts`. It stays outside
the production TypeScript project's `src`/`test` inputs because the docs-only
example is not present in the `web/` Docker build context.
This is NOT anonymous transport. IP/User-Agent/Origin still reach the platform.
The SDK sends website, actual hostname and session cache headers, reads
URL/title/referrer before sanitization, and reads its own localStorage switch.
Nginx strips sensitive headers before the collector, not before the browser's
request reaches Web. Web logs require their own privacy policy. Umami can derive
browser/OS/device/geo/session from transport metadata. With pinned 3.4.1 absent URL
stores empty path/query and fallback hostname localhost; title/referrer/screen/
language/distinct identity remain absent. Removing JSON fields does not disable
transport-derived processing. Operators must review consent, logs, access and law.
## Connect a platform: local Umami v3.4.1 demo
[The Compose example](examples/analytics/compose.yaml) runs Umami v3.4.1 (MIT),
Postgres 16 and an optional edge; SkillHub remains your existing web upstream.
From the repository root:
```sh
docker compose -f docs/examples/analytics/compose.yaml config
docker compose -f docs/examples/analytics/compose.yaml up -d
```
The default Web upstream is `host.docker.internal:80`, matching the release
Compose `WEB_PORT` default of 80. Override `SKILLHUB_WEB_UPSTREAM` with the actual
reachable host:port if different. The edge's `extra_hosts` maps this hostname to
`host-gateway` on native Linux; Docker Desktop/OrbStack already provide it. The
host Web listener must be reachable from containers. The edge inherits the standard
server-level `client_max_body_size 100M` for application proxying (100 MiB); it does
not reduce uploads to Nginx's default 1 MiB.
This creates a demo network and ephemeral database, not production storage. Use
an isolated local Docker environment with available network pools. Open
`http://localhost:8300` for local setup only. Umami's default `admin` / `umami`
credentials, the public DB password and app secret are DEMO ONLY: change them.
Create a synthetic website in Umami, copy its UUID into
[`observer.mjs`](examples/analytics/observe/v1/observer.mjs), then explicitly change
`enabled` and the consent hook for your local policy. Open SkillHub through
`http://localhost:8081` and exercise an enabled action; inspect the request to
`/_skillhub-umami/api/send` and Umami's event view. Tear down the local demo with
`docker compose -f docs/examples/analytics/compose.yaml down -v` (demo data is removed).
[`umami-adapter.mjs`](examples/analytics/observe/v1/umami-adapter.mjs) is the only
vendor-specific code. It loads `/script.js` with `data-auto-track="false"`, so
v3.4.1 installs no pageview, history hook, click listener or performance observer.
`data-host-url` is the same-origin `/_skillhub-umami` prefix, and
`data-before-send` names its sanitizer. Do not load another Umami tracker on the
same page (the SDK/global hook are singletons). It calls `umami.track(id, { event })`;
at most 20 valid intents wait in memory for up to three seconds while the script
loads (excess intents are dropped). They are released when ready, or dropped on
load failure, timeout or `stop()`. Once ready, every valid intent calls the tracker
independently: concurrent or hung requests do not block later intents. Each call's
wait is time-bounded; missing, blocked, throwing or rejecting trackers resolve
silently. There is no retry, durable queue or post-load backlog; best-effort counts
can undercount. After `stop()` returns, no new request is dispatched; only a
request whose fetch already started cannot be recalled (nor cancelled by a timeout).
The before-send hook keeps its immediate stop check and adds a non-enumerable
`toJSON` guard that re-checks consent when the SDK serializes at dispatch. In pinned
v3.4.1 this runs synchronously in the fetch expression inside the SDK's try/catch:
throwing on stop prevents fetch and is swallowed by the SDK. This relies on that
ordering; a different Umami version must re-verify it with the dispatch-boundary
regression tests in `web/analytics-umami-example.test.ts`.
The sanitizer allowlists the FINAL SDK payload, not just the callback. Only
`{ type: 'event', payload: { website, name, data: { event } } }` survives; website
must match configuration, name must match the ID shape (and Umami's 50-character
limit), event must be click/submit/keydown. It returns null for pageviews (including
unnamed `event` payloads), identify, performance and everything else. Hostname, URL,
title, referrer, screen, language, tag, identity and extra data keys are removed.
Values are read once, and only validated primitives are forwarded.
The SDK still reads title/URL/referrer in-page before sanitizing; it also reads
localStorage for its own `umami.disabled` switch. Those are SDK behaviors, not
behaviors of the vendor-neutral helper.
This is NOT anonymous transport or zero server processing: IP, User-Agent and
Origin still leave the browser. Umami sends `x-umami-website-id`,
`x-umami-hostname` (the actual hostname) and, after a response, `x-umami-cache`
(a session cache token). They are NOT sanitized JSON. The edge tightens UI
Referrer-Policy to `no-referrer` and strips Referer, Cookie and Authorization on
collector proxying; otherwise same-origin Referer can expose paths/queries.
Umami v3.4.1 hashes website/IP/User-Agent/salt into a server-side session and derives
browser/OS/device/geo from transport metadata. Removing JSON screen/language does
not disable these derivations; review access logs, retention, consent and local
law. Its missing-URL fallback stores empty url_path and fallback hostname
`localhost`, not the current page; url_query is an empty string (not SQL NULL in
v3.4.1), while title/referrer/screen/language stay null.
To replace the platform, replace the adapter and edge ingestion endpoints, keeping
the helper and static payload contract. No admin configuration page, SkillHub
analytics DB, persistent queue or multi-vendor SDK is introduced.
## Turn collection off
No injection means zero collection. `enabled: false` in the deployer's entry is
also inert: no helper initialization or tracker load. On consent withdrawal call
`window.skillhubAnalytics.stop()`; it removes listeners and denies SDK payloads,
including those awaiting dispatch. After it returns, no new request is dispatched;
only a request whose fetch already started cannot be recalled. The dispatch guard
relies on pinned v3.4.1 serializing at dispatch; re-run the adapter regression tests
to re-verify this ordering for a different Umami version. Resume only after approval
via `start()`. Platform-side disabling/deleting the website, Umami's local
`umami.disabled` switch, or blocking the two ingestion routes are additional
operator controls, not substitutes for consent. Removing injection and purging
HTML is the deployment rollback; default SkillHub never loads any of this.
## Optional deployment-owned Nginx injection
Prefer a separately maintained same-origin HTTPS module, optionally inserted by
Nginx `sub_filter`, not an app entrypoint modification. Start with
[the opt-in edge template](examples/analytics/nginx.conf). It is not loaded by the
product. Compose substitutes only the two upstream host:port variables. Publish
its HTTP listener on loopback behind your HTTPS terminator. Serve the supplied
`observer.mjs`, helper and adapter outside the app bundle; only enable the entry
after configuring website/rules/consent.
The sample filters only the UI fallback's default `text/html` type, once per
response, inserting a module before `</head>`. Module loading is deferred and no
application code depends on it; missing/failed JS must not stop SkillHub. The
versioned prefix location overrides SPA fallback, sets JavaScript MIME and nosniff,
and uses `try_files $uri =404` with directory listing off. The served directory
contains only the three JS modules, never `.d.mts` or config. Missing modules are
404, never index.html. Code is immutable: deploy changed bytes/config under a new
version directory and update the injection path (breaking contracts need a new
module version). The two exact Umami routes expose tracker and send only; all
other paths under that prefix return 404. Same-origin script-src/connect-src are
sufficient for this demo, subject to the existing policy below.
Deployment checklist:
1. Check `nginx -V` for http_sub_module (built with `--with-http_sub_module` or an
equivalent loaded module), then `nginx -t`. Module absence is a blocker, not an
invitation to weaken security. Review actual final config with `nginx -T`.
2. Upstream `Accept-Encoding ""` requests uncompressed HTML. Ensure upstream
gzip_static/Brotli/CDN compression cannot still send encoded bytes. Filtering
compressed input does not work; downstream compression may run after filtering.
Leave API, auth, download, asset and runtime-config locations unmodified.
3. Filtered HTML must not reuse unfiltered validators or cached 304 responses. The
example removes upstream ETag/Last-Modified/Content-MD5 and cache headers,
suppresses conditional requests and disables proxy cache, serving no-store HTML.
Purge old edge/CDN HTML on enable/rollback; test GET/HEAD, conditional headers,
existing browser caches and 200/304 behavior. Do not cache injected HTML under
the old unfiltered entity validator. API and immutable assets retain their policy.
4. Preserve existing CSP and all security headers. The script must satisfy the
current script-src policy (same-origin alone does not satisfy nonce-only or
strict-dynamic policies). A per-response nonce or approved external hash/SRI
policy needs deployment-specific coordination; otherwise do not enable injection.
Never add unsafe-inline/unsafe-eval or broad wildcards. Any reporter needs an
explicitly permitted connect-src endpoint; this example uses same-origin only.
Nginx add_header inheritance is location-sensitive: merge/repeat existing edge
security headers as necessary; do not drop or hide upstream CSP to make it work.
5. Test actual built SkillHub HTML, not just a synthetic HTML fixture: exactly one
script for UI routes, none for APIs/assets/downloads/auth endpoints; script MIME,
missing-script 404, root/deep links, compression, CSP, disabled delivery and
collector failure. Then browser-check native events, portals, guards and consent.
A fixture filter probe is not final-config, SkillHub or production acceptance.
### Supported sub-path mapping
The sample is root deployment. For a supported `/skillhub/` deployment, keep that
prefix on the edge-to-app proxy URI (proxy_pass without a trailing slash), and
configure the app normally with SKILLHUB_WEB_BASE_PATH=/skillhub/ and matching
same-origin API base. Its existing container entrypoint owns internal prefix
rewriting; do not rewrite it a second time at the edge.
Adapt the sample's UI `location /` to `location /skillhub/`; add an exact
`/skillhub` redirect to `/skillhub/`; scope the bypass regex to
`^/skillhub/(api/|oauth2/|login/oauth2/|\.well-known/|assets/|registry/|runtime-config\.js$|nginx-health)`.
Change the versioned module prefix location and injected src to
`/skillhub/_skillhub-observe/v1/` and
`/skillhub/_skillhub-observe/v1/observer.mjs`, plus the matching deny prefix.
With `root /srv`, install the three JS files under
`/srv/skillhub/_skillhub-observe/v1/`; relative imports follow automatically.
Also scope BOTH exact Umami endpoints and the deny prefix to
`/skillhub/_skillhub-umami/`, and change the entry's adapter `prefix` accordingly;
upstream `/script.js` and `/api/send` stay unchanged. Keep other site locations
unchanged; do not put sub_filter at http/server scope. Use any supported validated
base path consistently, preserving original request URIs. HTTPS/public registry,
OAuth callbacks and generated asset URLs still need the normal deployment setup.
Rollback: remove the sub_filter directives/injection location customization, test
configuration, reload through the normal deployment procedure, and purge cached
HTML. Existing cached versioned JS can remain temporarily but must not be referenced
or initialized. Verify new HTML has no script, no callback/reporting occurs and
SkillHub remains usable. This document does not authorize production changes.
Supported-platform deployment, initialization, verification, upgrade, shutdown and
custom adapter development are documented in the guide. Custom adapters require
reviewed code, registry validation, cancellation, final payload cleaning and exact
proxy routes; business components must remain vendor-neutral and passive.

View file

@ -53,6 +53,14 @@ Nginx 通过独立 include 片段选择已注册适配器的精确端点:Umami
部署配置变更经重建/重启 Web 使新页面生效;旧标签页不自动接收新环境参数。动态停止通过当前页面接口生效;紧急全局停止可关闭独立采集路由。文档不得宣称仅修改 env 即刻撤销已在发送的请求。模块使用正常发布构建产物与缓存策略,不把可变配置放入 immutable JS。
### 5. Serialization and loading implementation
PR-2 不新增 jq 或 Node 运行时依赖。镜像已有 BusyBox awk 负责有界 JSON 语法和部署控制字段校验,base64 将 JSON 作为数据追加到现有 no-store runtime-config.js;浏览器用 JSON.parse 解码,不经 eval,也不把任意 JSON 插入 JS 字面量。配置最大 64 KiB、最多 32 层,重复键拒绝。非法 rules 仍按既有 resolveRules 跳过,不升级为整个配置失败。
bootstrap 仅静态包含小型 gate 和注册参数校验;controller/helper 与 adapter 保持独立懒加载 chunk。在首个 React DOM commit 后的独立任务中启动 gate,不把正常挂载与 SDK 绑定。enabled=true 且 consent=denied 时不加载模块,但代理保留精确端点,供当前页显式 start() 授权后使用。静态 adapter factory 在动态导入返回后检查取消信号,迟到的导入不能重新创建 SDK。
旧示例目录仅保留指南指针。helper/adapter 与全部断言迁入产品测试树;历史 edge/Compose 只保留为测试 fixture,不是部署路径,不维护第二份采集实现。
## Risks and verification
重点验证默认零 SDK/采集请求、错误配置降级、页面启动不等统计 SDK、DNS失败不阻断Web、stop/init竞争、适配器字段清洗、根/子路径、100M原业务上传、登录/下载及配置回滚。固定 SDK 升级须验证真实发送顺序;不能将静态配置或模拟API测试描述为真实业务E2E。正式测试矩阵在实现和独立Review收敛后生成。

View file

@ -31,6 +31,6 @@ Issue #989 / PR #1010 已提供被动动作标签、通用 helper 和独立平
## Impact
影响按阶段区分:PR-1 仅涉及被动标签契约、示例、测试和接入文档;以下产品部署影响属于 PR-2。影响 Web 可选模块、运行时配置、容器启动、Compose/Helm、部署测试和文档。新增小型 jq 镜像依赖用于标准 JSON 校验与安全转义,避免用 envsubst 拼接任意配置。默认部署不启动统计服务、不加载采集模块、不外发事件。
影响按阶段区分:PR-1 仅涉及被动标签契约、示例、测试和接入文档;以下产品部署影响属于 PR-2。影响 Web 可选模块、运行时配置、容器启动、Compose/Helm、部署测试和文档。复用镜像已有 BusyBox awk 校验有界 JSON,使用 base64 将 JSON 安全传入现有 runtime-config,避免 envsubst 拼接任意配置,不新增 jq 或其他镜像依赖。默认部署不启动统计服务、不加载采集模块、不外发事件。
范围外:后台采集配置页、SkillHub 分析数据库、通用远程代码插件平台、用户级同意弹窗、可靠消息/重试队列、服务端业务成功事件、生产环境部署和自动合并。统计平台自己的数据库属于可选外部服务,不修改 SkillHub schema。

View file

@ -12,25 +12,25 @@
- [ ] 2.7 Independent parent review and publication; keep Issue #989 open.
## 3. PR-2 (follow-up): optional frontend capability
- [ ] 3.1 Move helper/adapter to a single product source under web/src/features/analytics and preserve all PR-1 assertions.
- [ ] 3.2 Add default-closed runtime loader and vendor-neutral controller/registry; register Umami as the first adapter.
- [ ] 3.3 Cover consent, validation, duplicate init, async stop races and adapter failures.
- [x] 3.1 Move helper/adapter to a single product source under web/src/features/analytics and preserve all PR-1 assertions.
- [x] 3.2 Add default-closed runtime loader and vendor-neutral controller/registry; register Umami as the first adapter.
- [x] 3.3 Cover consent, validation, duplicate init, async stop races and adapter failures.
- [ ] 3.4 Validate this OpenSpec change with the CLI when available and bind the follow-up PR/task.
## 4. PR-2 (follow-up): deployment
- [ ] 4.1 Safely serialize and validate configuration without breaking existing runtime config.
- [ ] 4.2 Add isolated proxy snippets with request-time DNS, certificate validation and exact endpoints.
- [ ] 4.3 Wire Docker/Compose/Helm defaults and reserved sub-path validation.
- [ ] 4.4 Provide optional persistent self-hosted Compose combination without another Nginx.
- [x] 4.1 Safely serialize and validate configuration without breaking existing runtime config.
- [x] 4.2 Add isolated proxy snippets with request-time DNS, certificate validation and exact endpoints.
- [ ] 4.3 Wire Docker/Compose/Helm defaults and reserved sub-path validation. (Implemented; Compose/runtime checks passed. Helm CLI rendering remains unverified.)
- [x] 4.4 Provide optional persistent self-hosted Compose combination without another Nginx.
## 5. PR-2 (follow-up): documentation
- [ ] 5.1 Replace source-editing quickstart with configuration-only production paths for supported platforms; distinguish custom adapter development.
- [ ] 5.2 Document fields, platform prerequisites, initialization, verification, troubleshooting, upgrade and shutdown.
- [ ] 5.3 Link deployment entry points and document adapter extension and actual privacy boundaries.
- [x] 5.1 Replace source-editing quickstart with configuration-only production paths for supported platforms; distinguish custom adapter development.
- [x] 5.2 Document fields, platform prerequisites, initialization, verification, troubleshooting, upgrade and shutdown.
- [x] 5.3 Link deployment entry points and document adapter extension and actual privacy boundaries.
## 6. PR-2 (follow-up): quality gates
- [ ] 6.1 Consolidated independent implementation review, fixes and final base checkpoint.
- [ ] 6.2 Reviewed requirement/scenario/test matrix and targeted source/deployment checks.
- [ ] 6.3 Exact-SHA release Compose S3/auth smoke and Chromium normal/changed/failure scenarios, including default zero module/SDK/request and root/sub-path upload/login/download/CSP/cache compatibility.
- [ ] 6.2 Reviewed requirement/scenario/test matrix and targeted source/deployment checks. (Worker matrix and local checks complete; independent review and Helm execution pending.)
- [ ] 6.3 Exact-SHA release Compose S3/auth smoke and Chromium normal/changed/failure scenarios, including default zero module/SDK/request and root/sub-path upload/login/download/CSP/cache compatibility. (Local browser/real-collector and HTTP proxy checks passed; full backend S3/auth smoke remains pending.)
- [ ] 6.4 CI/privacy/readiness, one validation report and retained preview for human acceptance.
- [ ] 6.5 Close Issue #989 only after configuration-only deployment is complete and verified, not when PR-1 merges.