跳到主要内容

可观测子系统 — Trace 上报到落库到查询

本章讲什么: 你的 Agent 跑一次,背后 prompt→model→tool→parser 一连串步骤。可观测子系统把每步记成一个 span,串成 trace,存进 ClickHouse,供你回放与排障。本章追三条路:① span 怎么上报落库 ② span 长什么样 ③ 怎么查回来 + 算指标。

1. 先建直觉:span 与 trace

  • span = 一段被计时的操作(一次模型调用、一次工具调用、一次 prompt 渲染)。有开始时间、耗时、输入、输出、状态。
  • trace = 同一次请求里所有 span,用 parent_id 串成的一棵树,trace_id 是这次请求的身份证。

和通用 APM(如 OpenTelemetry)一样的模型,但 CozeLoop 的 span 专为 LLM 场景定制:内建 model_nameinput_tokensoutput_tokensmodel_providerprompt_key 这些字段(见 span.go:40-57SpanField* 常量),span 类型也带 prompt/model/tool/agent/embedding 等 LLM 语义(span.go:65-80 SpanType*)。

2. 路径一:span 怎么上报落库

2.1 入口:IngestTraces

SDK 把一批 span POST 到 OpenAPI,最终进入 TraceServiceImpl.IngestTracestrace_service.go:1536):

// trace_service.go:1536(真实源码,节选+精简注释)
func IngestTraces(ctx, req) error {
processors := buildHelper.BuildIngestTraceProcessors(...) // 取一组上报期处理器
for _, p := range processors {
req.Spans, _ = p.Transform(ctx, req.Spans) // 逐个 transform(裁剪/脱敏/补字段)
}
traceData := &entity.TraceData{ Tenant: req.Tenant, SpanList: req.Spans, ... }
traceProducer.IngestSpans(ctx, traceData) // 丢进 MQ,不在请求里同步落库
}

关键设计:上报接口不直接写库,而是 transform 后丢 MQ,把昂贵的落库异步化,保护上报链路低延迟。

2.2 落库管道:OTel 风格的 collector

MQ 那头是一个仿 OpenTelemetry Collector 的管道IngestionServicedomain/trace/service/ingestion.go:18)启动一个 collector.Collector,它由三类工厂组装(:33):

阶段角色实现位置
receiver收数据(从 RocketMQ 拉 span)domain/trace/service/collector/receiver/rmqreceiver/
processor加工(批处理、转换)domain/trace/entity/collector/processor/
exporter落地(批量写 ClickHouse)domain/trace/entity/collector/exporter/

这套 Factories{Receivers, Processors, Exporters} 结构(ingestion.go:34-48)、Consumer 链、MakeFactoryMap 命名,全是 OTel Collector 的影子。复用成熟管道抽象而不是自己撸一套消费循环,是个聪明的工程选择。

管道里还有一层 ObserveConsumercollector/consumer/observe_consumer.go:31)做自监控:它包住真正的 consumer,按 PSM/租户分组打吞吐、延迟、span 数指标(:51-65),并用 stopwatchConsumer:73)扣掉内层耗时算出"本节点自身耗时"——一个干净的装饰器埋点模式。

SDK ─POST─▶ IngestTraces ─transform─▶ MQ(RocketMQ)

┌─────────────▼──────────────┐
│ Collector 管道 │
│ rmqreceiver 收 span │
│ ▼ │
│ processor 批处理/转换 │
│ ▼ │
│ exporter 批量写 CK │
│ (ObserveConsumer 全程埋点) │
└─────────────┬──────────────┘

ClickHouse spans 表

2.3 真正写 CK

exporter 最终调 SpansCkDaoImpl.Insertinfra/repo/ck/spans.go:52):批量 Create带 3 次重试,注释点明取舍——满足条件的批写入幂等,但网络错误重试可能导致重复写(:54-57)。这是"宁可偶尔重复也不丢"的可观测系统常见权衡。

3. 路径二:span 实体长什么样

loop_span.Spanspan.go:157)是中心数据结构。它的字段设计揭示了几个重要决策:

3.1 标签按类型分桶

// 示意,非源码:span 的标签存储(取自 span.go:178-187)
TagsString map[string]string // 字符串标签
TagsLong map[string]int64 // 整型标签
TagsDouble map[string]float64 // 浮点标签
TagsBool map[string]bool
// 另有 SystemTags*(系统注入)与之并行

为什么按类型分三个 map 而不是一个 map[string]any 因为落到 ClickHouse 要进强类型列,分桶后写库直接对应 tags_string/tags_long/tags_double 列,查询时也能用对应类型的索引与聚合(如对 tokens 求和必须是数值列)。GetSystemTagsspan.go:225)把它们统一拍平成字符串供展示。

3.2 大字段外移到对象存储

span 的 Input/Output 可能很大(整段对话)。ObjectStoragespan.go:195)+ AttrTosspan.go:215)把大输入/输出/多模态附件存到对象存储(TOS)只在 span 里留 key/URL。这呼应了评测侧反复出现的 IsContentOmitted()/大字段裁剪——全系统统一的"大字段不入主表"策略,控制 ClickHouse 行宽和 MQ 消息体。

3.3 TTL

span 带 TTLspan.go:93-102,3d~365d)与 LogicDeleteTime,按租户/套餐决定保留期。TTL 直接影响 ClickHouse 的过期清理与查询的时间窗口下界。

4. 路径三:怎么查回来 + 算指标

TraceServiceImpl 的查询方法群(trace_service.go)覆盖各种查法:

方法干什么
GetTrace (:1201)按 trace_id 拉一棵完整 trace
ListSpans (:1294)按条件分页列 span
SearchTraceOApi / ListSpansOApi (:1365/:1424)OpenAPI 对外查询
GetTracesAdvanceInfo (:1566)算 trace 的 token/成本/大小汇总
ListMetadata (:1700)列可筛选的元数据维度
ListAnnotations (:1801)列某 span 的标注(人工反馈/自动评分)

指标聚合直接下推到 ClickHouse

GetMetricsspans.go:95)/buildMetricsSql:109)很能体现 CK 的用法:它先复用普通查询 SQL 当内层子查询,外层再套 GROUP BY、时间分桶(toStartOfInterval + fromUnixTimestamp64Micro:135)、聚合函数,拼成:

SELECT <聚合表达式...>, <时间桶>
FROM ( <复用的过滤查询> )
GROUP BY <time_bucket, 维度...>

(模板 metricsSqlTemplatespans.go:93/:156)。把指标计算下推给 ClickHouse(而非拉回内存算),是用对了列存 OLAP 的强项——按时间桶/维度聚合海量 span 正是 CK 的主场。

5. 巧妙之处(本章带走的精华)

  • 上报与落库解耦:IngestTraces 只 transform + 发 MQ,落库异步化,保护上报延迟。trace_service.go:1536
  • 复用 OTel Collector 抽象:receiver→processor→exporter + Consumer 链,不重造消费循环。domain/trace/.../collector/
  • 装饰器式自监控ObserveConsumer 包住内层算自身耗时与吞吐。observe_consumer.go:31
  • 标签按类型分桶:对齐 ClickHouse 强类型列,写得进、查得快。span.go:178-187
  • 大字段外移对象存储:主表只存 key,控行宽控消息体。span.go:195
  • 指标 SQL 下推:子查询外套 GROUP BY + 时间桶,吃尽列存聚合能力。spans.go:109

6. 边界与局限

  • 落库异步:上报成功 ≠ 立即可查,存在 MQ→落库的延迟窗口。
  • CK 写入重试可能重复(spans.go:54),下游需容忍/去重。
  • 大字段在对象存储,查全文需二次拉取(AttrTos 的 URL)。

7. 代码地图(本章导航)

主题文件符号
上报入口 / 全部查询modules/observability/domain/trace/service/trace_service.goIngestTracesGetTraceListSpansGetTracesAdvanceInfoListMetadata
落库管道启动modules/observability/domain/trace/service/ingestion.goIngestionServiceIngestionCollectorFactoryNewIngestionServiceImpl
管道自监控modules/observability/domain/trace/entity/collector/consumer/observe_consumer.goObserveConsumerstopwatchConsumer
receiver/processor/exportermodules/observability/domain/trace/.../collector/receiver.Factoryprocessor.Factoryexporter.Factory
span 实体modules/observability/domain/trace/entity/loop_span/span.goSpanSpanField*SpanType*ObjectStorageTTL
CK 落库与查询modules/observability/infra/repo/ck/spans.goSpansCkDaoImpl.InsertGetGetMetricsbuildMetricsSql
应用层入口modules/observability/application/trace.goopenapi.goingestion.gometric.go

下一章把两子系统接起来:trace 怎么变数据集、在线评测怎么把分数写回 span。