跳到主要内容

遥测落库: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)、护栏、规则引擎的服务端逻辑分别在 第 040506 章。


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 exporterassets/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/requestobservability.ts
API 路由面板 fetch 的 HTTP 端点src/client/src/app/api/metricsobservability

主线走一遍(高层):

  1. 写: SDK 把 span 打到 4318(HTTP)或 4317(gRPC)→ Collector 的 otlp receiver 收下 → batch/memory_limiter 处理 → clickhouse exporter 通过 tcp://clickhouse:9000 写进 otel_traces 等表。
  2. 读: 浏览器请求 /api/metrics/request → Next.js 路由调用 getRequestsdataCollector 从连接池借一个 ClickHouse 客户端 → 拼一条 SELECT ... FROM otel_traces WHERE ... → 结果 JSON 化返回 → 面板画图。

3. 核心机制(逐个拆,由浅入深)

3.1 docker-compose 三件套:两个容器怎么串起来

它要解决的小问题: 数据库要先就绪,应用才能建表和收数据。怎么保证启动顺序?

思路: compose 里只有两个 service。clickhousehealthcheck,openlitdepends_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.ymlports: 段。Collector 不是独立容器,是打进 openlit 镜像里的, 所以「一个镜像 = 前端 + 后端 + Collector」。
  • 数据库连接靠 INIT_DB_* 环境变量透传:INIT_DB_HOST: clickhouseINIT_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_nametraces_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/metrics pipeline 多挂了 memory_limiter(1500 MiB 软上限),logs 只有 batch—— 日志量相对可控,不做内存限流。

3.3 建表双轨:init.sh(OTel 官方 schema)vs migrations(业务表)

它要解决的小问题: ClickHouse 里既要有 Collector 直接写的「原始遥测表」,又要有应用自己用的 「业务表」(评估、prompt、dashboard…)。两套表由谁建?

思路——两条完全独立的建表轨道:

轨道谁执行建什么何时
轨道 Aclickhouse 容器 initdb9 张 OTel 表 + 6 张 Controller 表容器首次/每次启动
轨道 BNext.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))

三个设计要点,记住它们后面读侧全靠这些:

  1. SpanAttributesMap(String,String) 第 02 章说的 gen_ai.request.modelgen_ai.usage.cost 这些全部作为 map 的 key 存进这一列,不是独立列。所以读侧到处是 SpanAttributes['gen_ai.usage.cost'] 这种取法。
  2. bloom_filter 跳数索引挂在 mapKeys/mapValues(SpanAttributes),让「按某个属性 key/value 过滤」不用全表扫。
  3. otel_traces_trace_id_ts_mv 物化视图——把每个 TraceIdmin/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.tsmigrationExist?.id 判断与末尾 prisma.clickhouseMigrations.create

迁移的编排顺序migrations/index.ts:37migrations():用 Promise.all 分组——独立建表 并行,有依赖的(Controller 一串 ALTER、providers→metadata)严格串行,注释里逐组标了为什么。

谁触发轨道 B? 两个入口:

  • 新增 DB 配置时:src/lib/db-config.ts:252addDatabaseConfigUserEntry 后调 migrations(id)
  • 健康检查兜底:src/lib/platform/clickhouse/helpers.ts:6pingClickhouse ping 通后调 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 的命令少数管理操作
pingSELECT 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_tracesSpanAttributes 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:

  1. 属性路径映射 getTraceMappingKeyFullPath(src/helpers/client/trace.ts:198)。面板代码里 写业务语义 key(costtotalTokens),经它翻成真实 SpanAttribute 路径。映射表在 src/constants/traces.ts:比如 cost.path = "usage.cost" + prefix = "gen_ai" → 最终 gen_ai.usage.cost这层间接让 SDK 改属性命名时,只改映射表,不用改每条 SQL。

  2. 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.tsgetRequestsConfig DISTINCT 折叠严格镜像(否则「取消勾选 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.ymlopenlit service 同时暴露 4317/4318
  • 建表双轨、各管一段。 原始遥测表(写侧、OTel 官方 schema)由 DB 容器 initdb 建,业务表(读侧) 由应用迁移建。两轨互不阻塞:即使应用没起,Collector 也能往已建好的 otel_traces 写。
  • 表名 + 属性路径两层常量。 common.tsOTEL_*_TABLE_NAME 统一表名,traces.tsTraceMapping 统一属性路径。上游改命名只改一处,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.yamlclickhouse-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.ymlservices.clickhouseservices.openlit(4317/4318/3000)
Collector 三段管道assets/otel-collector-config.yamlreceivers.otlpexporters.clickhouseservice.pipelines
OTel 表 + Controller 表建表(写侧)assets/clickhouse-init.shotel_tracesotel_metrics_gaugeotel_traces_trace_id_ts_mv
业务表迁移(读侧)src/client/src/clickhouse/migrations/index.tsmigrations()(:37)
单条迁移样板src/client/src/clickhouse/migrations/create-trace-analysis-migration.tsMIGRATION_IDCreateTraceAnalysisMigration
迁移去重执行src/client/src/clickhouse/migrations/migration-helper.tsmigrationHelper(查 clickhouseMigrations 去重)
统一查询入口src/client/src/lib/platform/common.tsdataCollector(:56)、OTEL_TRACES_TABLE_NAME(:14)
连接池src/client/src/lib/platform/clickhouse/clickhouse-client.tscreateClickhousePool(:38,max:20/min:5/testOnBorrow)
迁移触发src/client/src/lib/db-config.tssrc/client/src/lib/platform/clickhouse/helpers.tsmigrations(...)(db-config:252)、pingClickhouse/runClickhouseMigrations
trace 聚合查询src/client/src/lib/platform/request/index.tsgetTotalRequests(:82)、getRequests(:215)、PREDEFINED_GROUP_BY
metric 聚合查询src/client/src/lib/platform/observability.tsMETRIC_TABLES(:21)、metricUnionSelect(:161)、getSignalSummary(:249)、getMetrics(:372)
属性路径映射src/client/src/helpers/client/trace.tssrc/client/src/constants/traces.tsgetTraceMappingKeyFullPath(:198)、TraceMapping
WHERE 过滤器src/client/src/helpers/server/platform.tsgetFilterWhereCondition(:174,provider 三属性折叠)
面板 API 路由src/client/src/app/api/metrics/*src/client/src/app/api/observability/*metrics/request/total/route.tsobservability/metrics/route.tsobservability/summary/[signal]/route.ts