Windmill 功能使用遥测(feature_usage)接入指南:从埋点设计到落库验证
【免费下载链接】windmillOpen-source developer platform to power your entire infra and turn scripts into webhooks, workflows and UIs. Fastest workflow engine (13x vs Airflow). Open-source alternative to Retool and Temporal.项目地址: https://gitcode.com/GitHub_Trending/wi/windmill
feature_usage是 Windmill 的产品遥测累加器:以"天"为桶粒度的计数器,最终汇总进匿名使用统计载荷。它回答的是"这个功能有没有人用、大家选的是哪个变体",同时保证没有任何可识别身份的数据离开实例。本文以 docs/feature-telemetry.md 为骨架,结合开源仓库中的前端缓冲实现、后端注册与落库逻辑、迁移脚本与测试用例,完整讲解何时该埋点、如何设计词汇表、前端与后端两条接入路径,以及如何验证一条记录真的落进了数据库。
一、feature_usage 是什么:匿名、分桶、可聚合
在 Windmill 中,feature_usage是一张以天为分桶键的计数器表("day-bucketed counters"),所有计数最终滚入匿名的 usage-stats 载荷。它的设计目标很克制:只回答"有没有人用、选了哪种变体",不回答"谁在用、怎么用的"。
- 目前注册了49 个动作(action),分布在18 个功能(feature)上:
ai_session、ai_chat、ai_fix、ai_agent、ai_agent_eval、app_sandbox、datatable、flow_editor、flow_run、flow_step、home、run_form、debugger、trigger、command_script、hub_script、usage_meter、sso_groups_claim。 - 文档直言:产品中"几乎所有"功能都未埋点,因此每一个新的用户可见工作都是补上埋点的机会窗口。
从表结构可以最直观地理解这五个字段如何组合成"可按天、可按实体、可按 key 切分"的计数器。迁移脚本 backend/migrations/20260720081307_add_feature_usage.up.sql 定义了:
CREATE TABLE feature_usage ( feature VARCHAR(50) NOT NULL, kind VARCHAR(50) NOT NULL, key VARCHAR(100) NOT NULL DEFAULT '', entity_id VARCHAR(50) NOT NULL DEFAULT '', day DATE NOT NULL DEFAULT CURRENT_DATE, value BIGINT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (feature, kind, key, entity_id, day) ); -- 周期性的保留期删除只按 day 过滤;没有这个索引会全表扫描 -- (主键要经过另外四列才能到达 day)。 CREATE INDEX idx_feature_usage_day ON feature_usage (day);注意两点实现细节:value是BIGINT且可累加,同一(feature, kind, key, entity_id, day)组合在一天内通过 UPSERT 不断累加;而idx_feature_usage_day索引专门服务于 60 天保留期的清理任务(详见后文 backend/src/monitor.rs 中的删除逻辑)。
二、什么时候该埋点,什么时候该保持沉默
埋点不是"每个功能都加"。文档给出了清晰的两分法:
应当提议埋点的场景——当一个新用户可见的交互留下了一个真实的问题:
- 新的面板、模式、标签页、开关或入口:它到底有没有被发现、被使用?
- 存在互相竞争的 UX 路径,或新的默认值:哪一条胜出?
- 有 opt-in 或 beta 门槛:接受率(take rate)是多少?
- 多步骤流程:用户在哪个环节流失?
应当保持沉默的场景:
- 纯 bugfix、重构、内部管道(plumbing);
- 有用的信号必须依赖逐条标识数据(路径、名称、提示词、代码)才能表达——这些根本不允许记录,见"隐私规则"一节;
- 如果答案不会改变任何决策,埋点就是过度设计,什么都不用说。
还有一个工程协作上的硬性约定:埋点要在方案(plan)阶段就提出来,把具体词汇表写成文字,让用户用一行话决定保留或删除,而不是把它当成一个独立的问答打断流程。
三、设计埋点词汇表:五个字段与封闭取值集合
这是整套机制的核心。五个字段各有含义与硬性限制:
| 字段 | 含义 | 限制 |
|---|---|---|
feature | 产品领域,如ai_chat、flow_editor | ≤50 字符 |
kind | 该领域内的动作,如message、panel_placement;(feature, kind)是唯一被允许注册的组合 | ≤50 字符 |
key | 动作的某个切面——模式、标签页类型、工具名、provider:model。聚合按(feature, kind, key)分组,因此它决定一个计数器被拆成多少个可比较的桶 | ≤100 字符,identifier-shaped,可选 |
entity_id | 不透明的随机id(如会话 id),用于需要按实体分布而非扁平计数时 | ≤50 字符,identifier-shaped,可选 |
value | 增量,默认 1 | 被钳制在 1…1,000,000 |
Identifier-shaped的含义是:只允许 ASCII 字母数字加_ - : . /,不允许空格,其他任何字符都会被拒绝。
entity_id是解锁分布统计的关键:只有提供了它,载荷才会按(feature, kind, key)报告entity_count、total_value、median_value、p90_value、inactive_3d_entity_count。省略它,就是朴素的"这件事发生了多少次"计数器。
key 词汇必须封闭且精简:应该在调用点旁边用 TS union 枚举全部取值,让整套集合能在同一个地方被审查。仓库里的 frontend/src/lib/components/flows/flowEditorTelemetry.ts 就是教科书式示例——它把 flow 编辑器的面板位置拆成恰好三个事件:
export type FlowPanelPlacementEvent = /** 宽度在 `auto` 模式下把面板挤进了 modal。 */ | 'breakpoint_modal' /** 用户在面板处于 modal 时把它固定进了窗格。 */ | 'force_attach' /** 用户在面板处于窗格时把它固定弹出了 modal。 */ | 'force_detach'这个文件还展示了"什么时候不该计数"的判断力:forcedPlacementEvent只有在"用户钉住的偏好与面板当前所处位置不同"时才返回事件——auto不是被强制的放置,而钉在面板已处的位置则没有移动任何东西,计数它会与真正移动面板的覆盖操作无法区分。同样,createBreakpointTracker只在跨过 breakpoint 时计一次breakpoint_modal,因为mode在拖拽过程中会持续重新解析,按每次求值计数会把一次拖拽读成几百次。
四、接入配方:四步走,第 1 步和第 3 步跳过会"安静地失败"
文档给出了固定的四步配方,并特别警告:跳过第 1 步或第 3 步会安静地失败(fails quietly)。
第 1 步:在FEATURE_USAGE_KINDS注册 (feature, kind) 组合
注册表位于backend/windmill-common/src/feature_usage_ee.rs(该文件托管在windmill-ee-private私有仓库)。未注册的(feature, kind)会被is_recordable_event用一个裸的continue丢弃——没有报错、没有日志,浏览器端依然是 204。也就是说:纯前端埋点看起来"一切正常",实际上什么都没记录。
第 2 步:从前端记录
import { logFeatureUsage } from '$lib/utils/featureUsage' logFeatureUsage('flow_editor', 'panel_placement', { key: 'force_detach' })这是 fire-and-forget 调用。事件会按(workspace, feature, kind, key, entityId)在本地求和,然后:
- 每30 秒刷新一次;
- 在
visibilitychange→ hidden 时刷新; - 在
pagehide时刷新; - 每个请求最多50 条事件;
- 失败的批次直接丢弃,不重试。
这些常量的源码依据在 frontend/src/lib/utils/featureUsage.ts 中:FLUSH_INTERVAL_MS = 30_000、MAX_EVENTS_PER_REQUEST = 50。缓冲实现用Map<string, { workspace, event }>按(workspace, feature, kind, key, entityId)聚合,重复事件在本地累加value,这样即使 UI 很啰嗦,每次刷新也只产生一次 UPSERT。
值得注意的工程细节:发送端特意不用生成的 API 客户端,而是裸fetch并带keepalive: true(见 featureUsage.ts 中的flush实现),因为keepalive允许请求在标签页关闭/导航后完成——这正是最后一次 flush 发生的时机。而且每次 flush 会先同步发起所有分块请求再 await,因为 pagehide 刷新只能保护"已经发出的请求",keepalive无法拯救一个从未开始的 fetch。认证则依靠 token cookie(credentials: 'include'),请求目标是POST /w/{workspace}/workspaces/log_feature_usage,该路由在 backend/windmill-api/openapi.yaml 中定义为logFeatureUsage操作,实际挂在 backend/windmill-api-workspaces/src/workspaces.rs 的 workspaced 路由上。
对应的单元测试在 frontend/src/lib/utils/featureUsage.test.ts,覆盖了三个关键行为:重复事件按(feature, kind, key, entity)求和后只 flush 一批、按 workspace 拆分批次且没有 workspace 的事件被丢弃、pagehide flush 时在任一 send resolve 之前发起所有分块请求(否则关页瞬间会丢事件)。
第 3 步:更新披露文案
frontend/src/lib/components/InstanceSettings.svelte 列出了非 minimal 载荷包含的内容——这段文案出现了两次。任何新的计数器如果没有在披露里命名,实例就会"少披露了自己发送了什么"。文档特别提醒:这件事已经漂移过一次("This has already drifted once")。从该组件的披露文本可以看到 feature usage 的具体内容:"counts of which product features are used, including AI provider and model identifiers, the names of public hub scripts used, the languages debug sessions...";此外它还提供了 air-gapped(隔离网络)实例手动下载遥测数据的方式(windmill-telemetry-${date}-${signature}.json)。
第 4 步:验证有行落库
因为存在静默丢弃路径,"没有报错"证明不了任何事。验证方法是直接查库:
SELECT feature, kind, key, entity_id, day, value FROM feature_usage ORDER BY updated_at DESC LIMIT 10;另一个关键前提:采集位于privatefeature 之后,公共构建(CE)从 HTTP 路由到 Rust helper 都不会记录任何东西。必须用--features enterprise,private运行后端,否则无论埋点多么正确,这张查询都会一直为空。公共构建里对应的是惰性实现 backend/windmill-common/src/feature_usage_oss.rs:is_recordable_event恒返回false,log_feature_usage是空操作,flush_feature_usage直接返回Ok(())——因为 CE 实例从不发送 stats 载荷,计数只会写出没人读的行。
五、隐私规则:什么可以离开实例
只有聚合计数能离开实例,且仅当遥测开启、minimal 模式关闭时。硬性禁止:
- 永远不要把路径、提示词、脚本内容、workspace 名称、邮箱或任何用户标识符放进
key或entity_id; entity_id必须是不透明随机 id,绝不能映射回某个用户或资源;- 如果想要的信号只能用标识数据表达,那它根本不能被采集——放弃它。
数据生命周期:计数器聚合最近30 天的数据,行在60 天后被清理。清理逻辑并不依赖遥测发送器,而是独立跑在 backend/src/monitor.rs 的监控循环里,因此即使遥测被禁用、或构建没有 stats 调度器,保留期行也会被修剪:
// 60-day retention for anonymous feature-usage counters. Runs here (not only // in the telemetry sender) so rows are pruned even when telemetry is disabled // or the build has no stats scheduler. if let Err(e) = sqlx::query!("DELETE FROM feature_usage WHERE day < CURRENT_DATE - 60") .execute(db) .await { tracing::error!("Error deleting old feature_usage rows: {e}"); }仓库在隐私上还有更细的实践。例如 featureUsage.ts 中的logHubScriptPick与hubScriptUsageKey:公开 hub 的脚本被 slugify 后记录app/summary;而来自私有 hub 的脚本是客户自己的内容,因此在PRIVATE_HUB_MIN_VERSION及以上只记录"用了私有脚本"这个事实(key 塌缩为private)。同理,hubProjectUsageKey只有当 hub 域名与公共 hub 一致时才上报项目 slug,并且要求hubBaseUrlKnown已确认——因为 store 默认种入公共 hub 地址,一个读不到设置的实例否则会把自家项目名上报出去。
六、从后端记录:无 UI 的功能怎么埋
没有 UI 的功能用同样的方式从 Rust 记录:
windmill_common::feature_usage::log_feature_usage("trigger", "fired", kind.as_str());- 同一个注册表、同样的 key 规则、同样的"未注册即静默丢弃";
feature和kind是&'static str,调用点无法传入计算出的组合——这是编译期对封闭词汇表的强制;- 该调用只递增一个内存计数器然后返回,由 monitor 循环统一 flush 累加器,因此对热路径足够便宜——但只是单次调用便宜,不是免费:一个基数无限的 key 会让 map 不断增长,直到撞上 per-action 上限并开始丢弃新 key;
- 这条路径没有
entity_id、没有显式value——它只数"发生了多少次"。
feature_usage_ee持有注册表与写入器;公共构建拿到的是惰性的feature_usage_oss(见 backend/windmill-common/src/feature_usage_oss.rs),因为 CE 实例从不发送 stats 载荷。这也解释了为何 flush 循环在 backend/src/monitor.rs 中不按 server_mode 门控:feature-usage 计数器在任何一个被埋点的调用点都会累加,一个从不 flush 的 worker 会在关停时丢掉计数,所以无论什么模式都要 flush。
仓库里现成的后端埋点调用点可以当模板参考,例如:
- backend/windmill-api-debug/src/lib.rs 在调试会话创建时记录
("debugger", "session", lang_key); - backend/windmill-api-workspaces/src/datatable_migrations.rs 记录
("datatable", "migration_run" | "migration_rollback" | "migrations_toggled" | "migration_created", ...),并且特意不在"未变更的重新推送"上计数,以免wmill sync push每次都同步全部迁移而淹没有意义的计数; - backend/windmill-api/src/ai_evals/run.rs 与 backend/windmill-api/src/ai_evals/datasets.rs 记录
("ai_agent_eval", "run" | "dataset_created", ...),其中 run 的 key 表达"被测的是哪个状态的 agent"——部署版本、编辑中的改动还是旧版本。
七、端到端数据流:从一次点击到匿名统计载荷
把整条链路串起来看,一次前端埋点事件的生命周期是:
- 调用点:
logFeatureUsage('flow_editor', 'panel_placement', { key: 'force_detach' })(前端)或log_feature_usage("trigger", "fired", kind)(Rust); - 本地聚合:前端按
(workspace, feature, kind, key, entityId)在内存 Map 里累加value,最多攒 30 秒 / 50 条;Rust 端则递增内存计数器等待 monitor 循环 flush; - 传输:前端用带
keepalive的裸fetchPOST 到/w/{workspace}/workspaces/log_feature_usage,批次失败直接丢弃;Rust 端由 backend/src/monitor.rs 的循环调用flush_feature_usage写入数据库; - 注册表过滤:
is_recordable_event校验(feature, kind)是否在FEATURE_USAGE_KINDS白名单内,未注册即静默丢弃(无日志、前端照样 204); - 落库:按
(feature, kind, key, entity_id, day)主键 UPSERT 累加; - 汇总与清理:聚合最近 30 天数据进匿名 usage-stats 载荷(含
entity_count/median_value/p90_value等分布统计),60 天前的行由 monitor 循环按day索引删除。
值得重复一遍的三条实操铁律:新埋点必须在 plan 阶段带上完整词汇表;key 词汇用 TS union 封闭在调用点旁边;每一步都必须用"查库有行"来验证,而不是"没有报错"——因为整套机制对未注册组合是默认静默的。最后,采集只存在于--features enterprise,private构建中,公共构建的 feature_usage_oss.rs 是彻底的空操作。
【免费下载链接】windmillOpen-source developer platform to power your entire infra and turn scripts into webhooks, workflows and UIs. Fastest workflow engine (13x vs Airflow). Open-source alternative to Retool and Temporal.项目地址: https://gitcode.com/GitHub_Trending/wi/windmill
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考