代码架构
服务端 evs-aggr 的包结构、启动顺序、一次上报和一次查询经过哪些代码,以及管理界面、SDK、契约文件之间的关系。先读 系统架构 了解组件与数据流。
服务端的包
evs-aggr 是一个 Go 模块,入口在 cmd/evstats,业务代码都在 internal/ 下。
| 包 | 职责 |
|---|---|
cmd/evstats | 入口:读三个启动参数和同名环境变量、evstats version、evstats healthcheck |
internal/bootstrap | 启动顺序、表结构调和(reconcile.go)、节点、健康检查、优雅退出 |
internal/httpserver | 路由挂载、中间件(request_id、安全响应头、CSP、访问日志)、嵌入的管理界面 dist/ |
internal/collect | 采集接口:/sa、/sa.gif、/sdk/config、/sdk/heatmap、/replay;过滤与规范化(normalize.go)、UA 解析 |
internal/queue | Redis Stream 的封装:写入、消费组读取、认领超时消息 |
internal/ingest | 入库:按项目的分片攒批、解析 IP、插入 ClickHouse、确认、失败事件 |
internal/stat | 唯一的统计实现:模块注册表 registry.go、查询引擎、sql/*.sql 模板、结果缓存、CSV、自定义 SQL 沙箱 |
internal/agent | Agent 问数:会话、调用兼容 OpenAI 的模型、把注册表里的模块变成工具、SSE 流式回答 |
internal/openapi | /api/v1:按注册表挂路由、接口密钥鉴权、限流、CORS |
internal/admin | 管理接口:引导、用户与角色、项目、接口密钥、系统配置、分片、采集状态、看板与书签 |
internal/auth | 登录、验证码、找回密码、会话(Redis)、权限判断、操作日志 |
internal/meta | 系统表的读写(项目、用户、配置、看板、实验……),配置缓存 |
internal/behavior | 热力图叠加的一次性令牌、A/B 分流与 z 检验 |
internal/ch | ClickHouse HTTP 客户端与分片路由 |
internal/rds · bus | Redis 连接;进程间通知(配置、密钥、分片变更)走 Redis 发布订阅 |
internal/geo · mail · metrics | 离线 IP 库、发信、/metrics |
internal/confkeys | 全部系统配置项的定义:键、默认值、分组、说明、校验 |
internal/contracts · tools/gencontracts | 生成 api/ 下的契约文件,并在测试里核对与代码一致 |
internal/demo | 引导里选了创建时建「演示项目」:时间换算到当前时刻,写事件、回放、元数据、实验、看板 |
internal/demo/gen | 在内存里生成演示数据包,固定种子,任何机器上结果一样 |
internal/demosite | 示例站点「云表单」的页面:浏览器测试在上面跑 Web SDK,演示数据的点击坐标和回放也按它排 |
internal/testkit · apitest | 测试夹具、假模型、假 SMTP、端到端控制程序 e2ectl;接口用例 |
schema/ | 期望的表结构(meta.sql 系统表、data.sql 事件与聚合) |
api/ | 契约:手写的 openapi.yaml,生成的 openapi.json、modules.json、config-keys.json |
bench/ | 压测程序,只用于发版前测性能,不进二进制 |
启动顺序
- 读
-clickhouse、-redis与-port,空了再读EVS_CLICKHOUSE、EVS_REDIS、EVS_PORT,还空再用默认值。连不上 ClickHouse 或 Redis 就退出(交给容器或 systemd 重启)。端口默认51321,对所有网卡开放。 - 对
s0做表结构调和:缺表建表、缺列加列、视图定义变了就重建,变更写进evs_schema_change。类型冲突时拒绝启动,不动数据。 - 读系统配置。还没有管理员时
/health的setup是need_admin。 - 连接并调和追加的分片,然后启动入库的消费者、节点心跳、IP 库加载。启动时不建项目,演示项目由引导的第二步按需创建。
- 挂载路由,开始监听。收到 SIGTERM 时停止接收上报,把内存里的批次写完并确认,最多 15 秒。
多台进程同时启动时调和用 IF NOT EXISTS,互不干扰。
演示数据包
仓库和二进制里都没有现成的演示数据。首次引导的第二步选了创建,POST /dash/setup/demo 才调用 internal/demo/gen 在内存里生成数据包,再由 internal/demo 写库。数据包是四份 JSON:
| 部分 | 内容 |
|---|---|
project.json | 项目名称与设置、页面标题、事件和属性的显示名、A/B 实验、看板、丢弃计数 |
visitors.jsonl | 每行一个访客:匿名 ID、设备、系统、浏览器、屏幕、IP、地域 |
events.jsonl | 每行一个事件,约 1 万行,按时间排序 |
replays.jsonl | 每行一个回放分块(rrweb 事件) |
事件的时间写成 "at": "-3 14:05:09.120"(3 天前、项目时区的 14:05:09.120)或 "ago": 125.3(创建前 125.3 秒)。写库时换算成绝对时间,晚于当前时刻的丢掉,所以任何时候创建都是「最近 35 天」的数据;回放里 rrweb 事件的 timestamp 是相对回放开始的毫秒数,同样换算。其余字段(url_path、referrer_host、UTM、点击 ID、页面标题、终端与地域)在写库时按采集端的规则补齐。
写库前拿 Redis 锁 evs:demo:seed,拿到后再数一次 evs_project:重复提交、多台进程同时收到请求也只建一个,后到的请求等前一个写完再返回。项目行最后写,中途失败时项目表仍为空,可以重试。
生成器只用整数随机数和固定种子,任何机器上结果一样。gen 的测试检查两次生成的结果相同,并检查 A/B 实验的结论(定价页实验显著、首页实验不显著)没有变。结论变了时,用 DEMO_SEED_SEARCH=1 go test ./internal/demo/gen -run TestSeedSearch -v 找一个新的种子。
一次上报经过的代码
collect.Handler.collect 校验写入密钥、大小、gzip,逐条 Normalize(过滤、时钟纠偏、UA、UTM)
└─ queue.Add XADD evs:stream:ingest,成功才回复 accepted
ingest.Service(每个进程一组)
├─ queue.Read / AutoClaim 消费组读取;别的进程没确认的消息空闲超时后认领
├─ geo.Lookup IP → 国家、省、市(本机离线库)
├─ ch.Client.Insert 按项目所在的分片攒批插入 evs_event
└─ queue.Ack 插入成功才确认;单条被拒绝的写进 evs_ingest_fail
ClickHouse 物化视图 维护 evs_agg_*、evs_click、evs_attr_*一次查询经过的代码
stat.DashAPI(/dash/stats/<path>) openapi.API(/api/v1/projects/{id}/<path>) agent 工具(模块键)
└──────────────────────────────┬──────────────────────────────┘
stat.Engine.Prepare 按注册表校验参数、权限、日期跨度,读出项目的分片
stat.Engine.Run 先查 Redis 结果缓存;未命中时 Render 模板 → ch 查询 → 写缓存- 模板在
internal/stat/sql/*.sql,用 Go 的text/template定义,参数一律走 ClickHouse 的绑定参数{name:Type}。 - 每条打到事件表和聚合表的 SQL 都必须带
project_id = {project_id:UUID}与event_date范围,sql_static_test.go会挡住不带的模板。 - 缓存键由模块和规范化之后的参数组成,三个出口命中同一条。
- CSV 导出、Agent 工具拿到的是同一条 SQL 的
CSVWithNames结果,Agent 最多 200 行。
管理界面与服务端怎么对上
管理界面 evs-dash 只访问 /dash 下的接口,全部相对路径;生产构建放进 internal/httpserver/dist/,用 go:embed 打进二进制,浏览器只访问一个源。
evs-dash/src/modules.ts是模块注册表的前端副本,yarn test:registry与evs-aggr/api/modules.json对照,CI 的registry任务不一致就失败。- 分析页的路由由
modules.ts生成:/stat/<模块的 path>,参数都在地址栏里。 - 错误码、字段名与
openapi.json一致;界面文案在src/locales/,中英文键完全一致。
详见 仪表盘开发。
配置
系统配置不走环境变量,存在 s0 的 evs_config 表里,在「系统配置」页修改。
- 定义在
internal/confkeys:键名、默认值、分组、类型、是否可测试、两句说明。 go generate ./...把它导出成api/config-keys.json,官网的 配置项 页由它渲染(官网里的副本用evs-test的T-D09 --fix同步)。- 读配置走
meta.Store.Config:进程内缓存 60 秒,Redis 里再缓存一份;修改时通过 Redis 发布订阅通知所有进程重新读取。
契约文件
| 文件 | 来源 | 谁用 |
|---|---|---|
api/openapi.yaml | 手写,只有 /api/v1 | 生成 openapi.json |
api/openapi.json | go generate ./... | 服务的 GET /openapi.json、官网 API 参考、测试 |
api/modules.json | go generate ./...,来自注册表 | 仪表盘的注册表对照 |
api/config-keys.json | go generate ./...,来自 confkeys | 官网配置项页、仪表盘对照 |
internal/contracts 的测试检查生成文件与代码一致、契约只含公开路径、契约路径与路由表一致。改了注册表或配置项忘记重新生成,测试会失败。
SDK
三端 SDK 在 evs-sdks/,各自发布、各自测试,但发的是同一种报文。服务端的规范化在 internal/collect/normalize.go,A/B 分流算法在 Web SDK 与 internal/behavior 各有一份,共用 evs-sdks/web/test/ab-vectors.json 测试向量。详见 采集协议与 SDK。