遥测落库:OTel Collector → ClickHouse → 面板
30 秒导读: 第 02 章讲了一次 LLM 调用如何在客户端变成一个带
gen_ai.*属性的 span。这一章讲服务端:这个 span 怎么通过网络进到数据库、存成什么样、 面板又怎么把它查回来画成图。一句话——SDK 发 OTLP → 镜像内置的 OTel Collector 收 → clickhouse exporter 写进几张固定表 → Next.js 用连接池查 ClickHouse 聚合成面板。
1. 这是什么(零基础也能懂)
一句话定义: OpenLIT 的服务端是一个「遥测仓库 + 查询前端」——它接住 SDK 吐出的 OpenTelemetry 数据,原样存进 ClickHouse(一个专门做分析查询的列式数据库),再由 Next.js 后端把这些数据聚合成面板上的成本曲线、trace 瀑布图、token 统计。
解决什么问题 / 给谁用: 假设你已经在应用里加了 openlit.init(),每次 LLM 调用都会往外
发一条遥测。这些数据总得有地方存、有办法查。OpenLIT 服务端就是这个「有地方 + 有办法」:
你 docker compose up 起两个容器,SDK 把数据发到 4318 端口,打开 localhost:3000 就能看图。
它由三块拼成:
| 块 | 干什么 | 长在哪 |
|---|---|---|
| OTel Collector | 收 OTLP、批处理、写库 | openlit 镜像内置,暴露 4317/4318 |
| ClickHouse | 存 trace / log / metric | 独立 clickhouse 容器 |
| Next.js 后端 | 查 ClickHouse、聚合、出图 | openlit 镜像里的 web app,端口 3000 |
用起来什么样: 一份 docker-compose.yml 就是全部部署。SDK 侧只要一个环境变量:
# SDK 侧:把遥测发到 collector 的 OTLP HTTP 端口(示意)
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
docker compose up -d # 起 clickhouse + openlit 两个容器
# 打开 http://localhost:3000 → Requests / Traces / Metrics 面板即有数据
一句话直觉: 把整条链路想成快递——SDK 是寄件人,OTLP 端口(4317/4318)是收件窗口,
Collector 是分拣中心(按 trace/log/metric 分流),ClickHouse 是仓库货架(五种货架各放一类),
面板是仓库管理员按需盘点出报表。
本章只讲存储与读取通路。评估(LLM-as-a-Judge)、护栏、规则引擎的服务端逻辑分别在 第 04、05、06 章。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从左到右是数据的一生——SDK 发出、经 Collector 落库、被 Next.js 查回。 上半条是写路径(遥测进库),下半条是读路径(面板出图),两条在 ClickHouse 交汇。
写路径(遥测进库)
┌──────────┐ OTLP ┌───────────────────────────┐ TCP:9000 ┌──────────────┐
│ 你的应用 │ ───────▶ │ openlit 镜像内置 │ ───────────▶ │ ClickHouse │
│ openlit │ 4317/gRPC│ OTel Collector │ clickhouse │ (列式库) │
│ .init() │ 4318/HTTP│ receiver→processor→exporter│ exporter │ │
└──────────┘ └───────────────────────────┘ │ otel_traces │
│ otel_logs │
读路径(面板出图) │ otel_metrics_│
┌──────────┐ HTTP ┌───────────────────────────┐ HTTP:8123 │ gauge/sum/ │
│ 浏览器 │ ◀──────▶ │ Next.js 后端(:3000) │ ◀──────────▶ │ histogram/ │
│ 面板 UI │ /api/... │ dataCollector + 连接池 │ @clickhouse │ summary/… │
└──────────┘ │ SQL 聚合 gen_ai.* 属性 │ /client │ │
└───────────────────────────┘ └──────────────┘
部件一句话职责:
| 部件 | 干什么 | 文件 |
|---|---|---|
| 两个容器编排 | clickhouse + openlit,健康检查串联启动 | docker-compose.yml |
| Collector 管道 | OTLP receiver → batch/memory_limiter → clickhouse exporter | assets/otel-collector-config.yaml |
| 建表(写侧) | 容器初始化时建 OTel 官方 schema + Controller 表 | assets/clickhouse-init.sh |
| 建表(读侧) | Next.js 应用层的业务表迁移 | src/client/src/clickhouse/migrations/* |
| 连接层 | 连接池 + dataCollector 统一查询/写入/迁移入口 | src/client/src/lib/platform/common.ts |
| 读查询 | 把 SpanAttributes['gen_ai.*'] 聚合成面板数据 | src/client/src/lib/platform/request、observability.ts |
| API 路由 | 面板 fetch 的 HTTP 端点 | src/client/src/app/api/metrics、observability |
主线走一遍(高层):
- 写: SDK 把 span 打到
4318(HTTP)或4317(gRPC)→ Collector 的otlpreceiver 收下 →batch/memory_limiter处理 →clickhouseexporter 通过tcp://clickhouse:9000写进otel_traces等表。 - 读: 浏览器请求
/api/metrics/request→ Next.js 路由调用getRequests→dataCollector从连接池借一个 ClickHouse 客户端 → 拼一条SELECT ... FROM otel_traces WHERE ...→ 结果JSON化返回 → 面板画图。
3. 核心机制(逐个拆,由浅入深)
3.1 docker-compose 三件套:两个容器怎么串起来
它要解决的小问题: 数据库要先就绪,应用才能建表和收数据。怎么保证启动顺序?
思路: compose 里只有两个 service。clickhouse 带 healthcheck,openlit 用
depends_on: condition: service_healthy 等它健康了再起。
clickhouse 启动
│ volume 挂 assets/clickhouse-init.sh → /docker-entrypoint-initdb.d/init.sh
│ 首次启动执行 init 脚本:建库 + 建 9 张 OTel 表 + 6 张 Controller 表
▼
healthcheck: clickhouse-client 'SELECT 1' 通过
│
▼
openlit 启动(depends_on: clickhouse service_healthy)
├─ 内置 OTel Collector,ports 暴露 4317/4318
├─ 挂 assets/otel-collector-config.yaml → /etc/otel/otel-collector-config.yaml
└─ Next.js web:3000
关键细节(真实源码):
openlit容器同时暴露三个口——3000(web)、4317(OTLP gRPC)、4318(OTLP HTTP),见docker-compose.yml的ports:段。Collector 不是独立容器,是打进 openlit 镜像里的, 所以「一个镜像 = 前端 + 后端 + Collector」。- 数据库连接靠
INIT_DB_*环境变量透传:INIT_DB_HOST: clickhouse、INIT_DB_PORT: 8123(HTTP)。注意 Collector 走9000(native TCP)写、Next.js 走8123(HTTP)读,同一个 库两个协议口。 CLICKHOUSE_ALWAYS_RUN_INITDB_SCRIPTS: true让 init 脚本每次启动都跑;脚本里全是CREATE TABLE IF NOT EXISTS,所以幂等、可重复执行。
3.2 Collector 管道:一条 OTLP 怎么变成一次 INSERT
它要解决的小问题: 网络上飞来的 OTLP 字节流,怎么可靠、成批地写进列式库?
思路: OTel Collector 的经典三段式——receiver(收)→ processor(整形)→ exporter(写),
在 assets/otel-collector-config.yaml 里配好,按 logs/traces/metrics 三条独立 pipeline 跑。
图示(三条 pipeline 共用 receiver 与 exporter):
┌─ traces → [memory_limiter, batch] ─┐
otlp receiver ───┼─ metrics → [memory_limiter, batch] ─┼─→ clickhouse exporter
(0.0.0.0:4317 └─ logs → [batch] ─────────────────┘ tcp://<host>:9000
/4318) database/user/pass 来自 env
真实实现(assets/otel-collector-config.yaml):
exporters:
clickhouse:
endpoint: tcp://${env:INIT_DB_HOST}:9000?dial_timeout=10s
database: ${env:INIT_DB_DATABASE}
ttl: 730h # 数据保留 730 小时 ≈ 30 天
logs_table_name: otel_logs
traces_table_name: otel_traces
timeout: 5s
retry_on_failure: # 写失败会退避重试,最长 300s
enabled: true
关键细节 / 坑:
- exporter 的表名约定是「隐含 schema 契约」。 配置里只显式写了
logs_table_name和traces_table_name;metric 按类型自动分五张表(otel_metrics_gauge/_sum/_histogram/_summary/_exponential_histogram),配置文件里以注释说明。这五张表名后面读侧会硬编码用到。 - 两处
ttl要对上: exporter 配ttl: 730h,建表 SQL 里也TTL ... + toIntervalHour(730)。 这是存储侧的自动过期——超 30 天的分区被丢掉(ttl_only_drop_parts = 1,按整分区删更省)。 traces/metricspipeline 多挂了memory_limiter(1500 MiB 软上限),logs只有batch—— 日志量相对可控,不做内存限流。
3.3 建表双轨:init.sh(OTel 官方 schema)vs migrations(业务表)
它要解决的小问题: ClickHouse 里既要有 Collector 直接写的「原始遥测表」,又要有应用自己用的 「业务表」(评估、prompt、dashboard…)。两套表由谁建?
思路——两条完全独立的建表轨道:
| 轨道 | 谁执行 | 建什么 | 何时 |
|---|---|---|---|
| 轨道 A | clickhouse 容器 initdb | 9 张 OTel 表 + 6 张 Controller 表 | 容器首次/每次启动 |
| 轨道 B | Next.js 应用启动时 | 评估/prompt/dashboard/rule 等业务表 | 连上 DB 后触发 |
轨道 A(assets/clickhouse-init.sh) 是纯 shell + clickhouse-client,直接 CREATE TABLE。
它建的 otel_traces 就是 Collector 写入的目标,schema 与 OTel ClickHouse exporter 官方一致:
-- assets/clickhouse-init.sh:otel_traces(节选)
CREATE TABLE IF NOT EXISTS otel_traces (
`Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
`TraceId` String, `SpanId` String, `ParentSpanId` String,
`SpanName` LowCardinality(String), `ServiceName` LowCardinality(String),
`SpanAttributes` Map(LowCardinality(String), String), -- gen_ai.* 全塞这
`Duration` UInt64, `StatusCode` LowCardinality(String),
INDEX idx_span_attr_key mapKeys(SpanAttributes) TYPE bloom_filter(0.01) ...
) ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp))
三个设计要点,记住它们后面读侧全靠这些:
SpanAttributes是Map(String,String)。 第 02 章说的gen_ai.request.model、gen_ai.usage.cost这些全部作为 map 的 key 存进这一列,不是独立列。所以读侧到处是SpanAttributes['gen_ai.usage.cost']这种取法。- bloom_filter 跳数索引挂在
mapKeys/mapValues(SpanAttributes)上,让「按某个属性 key/value 过滤」不用全表扫。 otel_traces_trace_id_ts_mv物化视图——把每个TraceId的min/max(Timestamp)预聚合到otel_traces_trace_id_ts,加速「按 trace id 找时间范围」。
轨道 B(src/client/src/clickhouse/migrations/) 是 TypeScript 迁移,应用侧管理。每个迁移文件
长一个样:声明一个 MIGRATION_ID + 一组 CREATE/ALTER SQL,交给 migrationHelper 执行。
// src/clickhouse/migrations/create-trace-analysis-migration.ts(节选)
const MIGRATION_ID = "create-trace-analysis-table";
const queries = [`CREATE TABLE IF NOT EXISTS openlit_trace_analysis (...) ENGINE = MergeTree() ...`];
const { migrationExist, queriesRun } = await migrationHelper({
clickhouseMigrationId: MIGRATION_ID, databaseConfigId, queries,
});
migration-helper.ts 的去重逻辑:先在 Prisma 表 clickhouseMigrations 里查 (databaseConfigId, clickhouseMigrationId) 是否已跑过,跑过就直接返回;否则逐条经 dataCollector(..., "exec") 执行,
全部成功才把 MIGRATION_ID 记进 Prisma,实现「只跑一次」。见
migration-helper.ts 的 migrationExist?.id 判断与末尾 prisma.clickhouseMigrations.create。
迁移的编排顺序在 migrations/index.ts:37 的 migrations():用 Promise.all 分组——独立建表
并行,有依赖的(Controller 一串 ALTER、providers→metadata)严格串行,注释 里逐组标了为什么。
谁触发轨道 B? 两个入口:
- 新增 DB 配置时:
src/lib/db-config.ts:252在addDatabaseConfigUserEntry后调migrations(id)。 - 健康检查兜底:
src/lib/platform/clickhouse/helpers.ts:6的pingClickhouseping 通后调runClickhouseMigrations()(内部migrations(dbConfig.id))。
3.4 连接层:dataCollector —— 所有 ClickHouse 交互的唯一门
它要解决的小问题: 建表、查数据、写入、ping,全都要连 ClickHouse。总不能每处各写一遍连接 逻辑。怎么收口?
思路: 一个函数 dataCollector 收所有活儿,靠第二个参数 clientQueryType 分派五种操作;连接
一律从 generic-pool 连接池借还。
真实实现(src/lib/platform/common.ts:56):
export async function dataCollector(
{ query, format = "JSONEachRow", table, values, enable_readonly, ... },
clientQueryType: "query" | "command" | "insert" | "exec" | "ping" = "query",
dbConfigId?: string
): Promise<DataCollectorType> {
// 1. 按 dbConfigId(或当前用户)取 DatabaseConfig
// 2. clickhousePool = createClickhousePool(dbConfig); client = 从池 acquire()
// 3. 按 clientQueryType 分派:query→client.query、insert→client.insert、
// exec→client.exec、command→client.command、ping→SELECT 1
// 4. finally: clickhousePool.release(client) // 关键:必还池
}
五种操作各自的用途:
| clientQueryType | 用途 | 谁在用 |
|---|---|---|
query | 读,返回 result.json() | 所有面板查询 |
insert | 批量写行 | 迁移里的种子数据、业务写入 |
exec | 执行 DDL(CREATE/ALTER) | 迁移 |
command | 执行返回 query_id 的命令 | 少数管理操作 |
ping | SELECT 1 探活 | 健康检查 |
连接池(src/lib/platform/clickhouse/clickhouse-client.ts:38):
return createPool(getClickHouseFactoryOptions(connectionObject), {
max: 20, min: 5, // 常驻 5、峰值 20 个连接
idleTimeoutMillis: 30000,
testOnBorrow: true, // 借出前先 ping,坏连接不给出去
acquireTimeoutMillis: 5000,
});
validate 里对 client.ping() 失败的连接直接 close 并 reject——坏连接不进业务。URL 由
constructURL(host, port) 拼(默认补 http://),读侧用的是 HTTP 口 8123。
表名常量集中在 common.ts:14 起:OTEL_TRACES_TABLE_NAME = "otel_traces" 等。读侧不裸写
表名,全 import 这些常量——建表 schema(3.3)与查询(3.5)靠这组常量对齐。
3.5 读取通路:SpanAttributes 怎么被聚合成面板
它要解决的小问题: 面板要「过去 24h 的总请求数 + 环比」「按 provider 分组的成本」这类聚合。
数据都在 otel_traces 的 SpanAttributes map 里,怎么查?
思路: 每个面板卡片对应一个 src/lib/platform/** 里的函数,拼一条 SQL 交给 dataCollector。
两个横切关注点被抽成了 helper——属性路径映射和 WHERE 过滤器。
主线(trace 类面板,以「总请求数」为例):
浏览器 fetch /api/metrics/request/total
│
▼ src/app/api/metrics/request/total/route.ts
POST 解析 timeLimit/operationType → 校验 → getTotalRequests(params)
│
▼ src/lib/platform/request/index.ts:82
拼 SQL:SELECT COUNT(*) FROM otel_traces WHERE <过滤>
并 JOIN 上一周期做「环比」
│
▼ dataCollector({ query }) → 连接池 → ClickHouse → JSON
▼ 面板画「12,345(↑8%)」
两个关键 helper:
-
属性路径映射
getTraceMappingKeyFullPath(src/helpers/client/trace.ts:198)。面板代码里 写业务语义 key(cost、totalTokens),经它翻成真实 SpanAttribute 路径。映射表在src/constants/traces.ts:比如cost.path = "usage.cost"+prefix = "gen_ai"→ 最终gen_ai.usage.cost。这层间接让 SDK 改属性命名时,只改映射表,不用改每条 SQL。 -
WHERE 构造器
getFilterWhereCondition(src/helpers/server/platform.ts:174)。把时间范围、 模型、provider、成本上限等筛选拼成 SQL 条件。时间条件长这样:Timestamp >= parseDateTimeBestEffort('<start>') AND Timestamp <= parseDateTimeBestEffort('<end>')provider 过滤有个三属性折叠的巧思:下拉里的 provider 值可能来自
gen_ai.system(LLM)、db.system(向量库)、coding_agent.client(编码 agent 厂商)三个不同 属性,所以 WHERE 用OR同时匹配三者——见platform.ts:211附近,与request/index.ts的getRequestsConfigDISTINCT 折叠严格镜像(否则「取消勾选 cursor」会失效)。
metric 类面板(observability.ts)不一样:metric 分散在五张表,要先 UNION。
问题:一个「metric」逻辑上可能落在 gauge/sum/histogram/summary/exponential_histogram 任意表,取值
列也不同(gauge 用 Value,histogram 用 Sum)。解法是 metricUnionSelect
(src/lib/platform/observability.ts:161):把选中的表各 SELECT 一段、统一投影成
metric_type / metric_value / metric_sample_count 三个别名,再 UNION ALL。
// observability.ts:21 五张表 + 各自取值/计数列
const METRIC_TABLES = [
{ table: OTEL_METRICS_GAUGE_TABLE_NAME, type: "gauge", valueExpr: "Value", countExpr: "1" },
{ table: OTEL_METRICS_HISTOGRAM_TABLE_NAME, type: "histogram", valueExpr: "Sum", countExpr: "Count" },
// ... sum / summary / exponential_histogram
];
getMetrics(observability.ts:372)在这个 UNION 子查询外面再 GROUP BY MetricName, metric_type, ServiceName 聚合出 latestValue/avg/min/max/pointCount,就是 Metrics 面板一行行的数据。
getSignalSummary(observability.ts:249)则按信号类型(traces/exceptions/logs/metrics)出时间
分桶的概览曲线——桶大小 getSummaryBucket 随时间跨度自适应(≤2 天按小时、≤45 天按天…)。
4. 巧妙之处(可借鉴)
- Collector 内置进应用镜像。 不额外拉一个
otel-collector容器,少一个运维对象;代价是 Collector 与 web 同生命周期。 见docker-compose.yml的openlitservice 同时暴露4317/4318。 - 建表双轨、各管一段。 原始遥测表(写侧、OTel 官方 schema)由 DB 容器 initdb 建,业务表(读侧)
由应用迁移建。两轨互不阻塞:即使应用没起,Collector 也能往已建好的
otel_traces写。 - 表名 + 属性路径两层常量。
common.ts的OTEL_*_TABLE_NAME统一表名,traces.ts的TraceMapping统一属性路径。上游改命名只改一处,SQL 不动——这是文档/代码抗漂移的同款思路。 - map 列 + bloom_filter 跳数索引。 用一列
Map装任意gen_ai.*属性(schema 不随 SDK 加属性 而变),再用mapKeys/mapValues上的 bloom_filter 补回「按属性过滤」的性能。灵活与速度兼得。 testOnBorrow+ validate ping。 连接池借出前先探活,坏连接当场销毁,避免把断连塞给查询。
5. 边界与局限(诚实)
- 单库多协议口耦合。 读走
8123、写走9000,两个口都得通;compose 里 Collector 的endpoint硬编码:9000,换端口要同时改配置和 compose。 - 30 天固定保留。 exporter
ttl: 730h与建表TTL 730h两处写死;要改保留期得同时动otel-collector-config.yaml和clickhouse-init.sh(且 init 用IF NOT EXISTS,已建的表 改 TTL 还得单独ALTER)。 - SQL 靠字符串拼接。 读侧查询大量用模板字符串拼 SQL,值虽有
escapeClickHouseString转义与字段白名单(如ALLOWED_FIELD_GROUP_BY)兜底,但注入面比参数化查询大,改动需谨慎。 - metric UNION 的成本。 一次 metric 查询要扫五张表再 UNION;
selectedConfig.metricTypes能把 表集缩小(metricUnionSelect里的filter),不指定类型时是全五张。 - 本章不覆盖: 评估结果表(
openlit_trace_analysis等)如何被写入与消费,属 第 04 章;护栏、规则引擎的服务端在 05/06。
6. 代码地图(导航索引)
| 主题 | 文件(相对克隆根) | 关键符号 |
|---|---|---|
| 两容器编排 / 端口 / 健康检查 | docker-compose.yml | services.clickhouse、services.openlit(4317/4318/3000) |
| Collector 三段管道 | assets/otel-collector-config.yaml | receivers.otlp、exporters.clickhouse、service.pipelines |
| OTel 表 + Controller 表建表(写侧) | assets/clickhouse-init.sh | otel_traces、otel_metrics_gauge、otel_traces_trace_id_ts_mv |
| 业务表迁移(读 侧) | src/client/src/clickhouse/migrations/index.ts | migrations()(:37) |
| 单条迁移样板 | src/client/src/clickhouse/migrations/create-trace-analysis-migration.ts | MIGRATION_ID、CreateTraceAnalysisMigration |
| 迁移去重执行 | src/client/src/clickhouse/migrations/migration-helper.ts | migrationHelper(查 clickhouseMigrations 去重) |
| 统一查询入口 | src/client/src/lib/platform/common.ts | dataCollector(:56)、OTEL_TRACES_TABLE_NAME(:14) |
| 连接池 | src/client/src/lib/platform/clickhouse/clickhouse-client.ts | createClickhousePool(:38,max:20/min:5/testOnBorrow) |
| 迁移触发 | src/client/src/lib/db-config.ts、src/client/src/lib/platform/clickhouse/helpers.ts | migrations(...)(db-config:252)、pingClickhouse/runClickhouseMigrations |
| trace 聚合查询 | src/client/src/lib/platform/request/index.ts | getTotalRequests(:82)、getRequests(:215)、PREDEFINED_GROUP_BY |
| metric 聚合查询 | src/client/src/lib/platform/observability.ts | METRIC_TABLES(:21)、metricUnionSelect(:161)、getSignalSummary(:249)、getMetrics(:372) |
| 属性路径映射 | src/client/src/helpers/client/trace.ts、src/client/src/constants/traces.ts | getTraceMappingKeyFullPath(:198)、TraceMapping |
| WHERE 过滤器 | src/client/src/helpers/server/platform.ts | getFilterWhereCondition(:174,provider 三属性折叠) |
| 面板 API 路由 | src/client/src/app/api/metrics/*、src/client/src/app/api/observability/* | metrics/request/total/route.ts、observability/metrics/route.ts、observability/summary/[signal]/route.ts |