Trace 观测:给每一次调用拍一张"体检片"
Day 13 的实验一跑就是几千次模型调用,出了问题怎么排查?答案是可观测性(Observability)模块——backend/modules/observability。今天走一遍"SDK 上报一条 Span → 落进 ClickHouse → 前端查询展示"的完整链路,这是理解"分布式追踪"最好的实战案例。
为什么需要 Trace
ParentID 连成树。观测系统要做的事就是:接收海量 Span 上报、存起来、支持按各种条件快速查询、在前端画成一棵树或火焰图。Span 长什么样
核心结构在 backend/modules/observability/domain/trace/entity/loop_span/span.go:157-182(节选真实字段):
type Span struct {
StartTime int64 `json:"start_time"` // 微秒
SpanID string `json:"span_id"`
ParentID string `json:"parent_id"` // 靠这个字段串成一棵树
TraceID string `json:"trace_id"` // 同一次请求下所有 Span 共享
DurationMicros int64 `json:"duration_micros"`
SpanType string `json:"span_type"` // model / tool / prompt / …
Method string `json:"method"`
StatusCode int32 `json:"status_code"`
Input string `json:"input"`
Output string `json:"output"`
SystemTagsString map[string]string `json:"system_tags_string"` // 系统自动打的标签
TagsString map[string]string `json:"tags_string"` // 用户自定义标签
}
TagsString 系列字段为什么按"类型"拆成好几个 map
注意有 TagsString / SystemTagsLong / SystemTagsDouble 等好几个不同类型的 map,而不是一个 map[string]interface{}。这是为了配合 Day 05 会讲的"ClickHouse Map 列"——ClickHouse 对同构 Map(值类型统一)的存储和查询效率远高于"什么都能装"的 JSON 列,所以在写入前就按值类型分好桶,牺牲一点点结构灵活性换查询性能。model(模型调用)、tool(工具调用)、prompt(Prompt 渲染)、chain/graph(编排节点,呼应之前教程里编排引擎的概念)。前端会根据 SpanType 决定用什么图标、展示什么专属字段(比如模型 Span 展示 token 用量,工具 Span 展示参数)。上报入口:三条路都能把 Span 送进来
backend/api/handler/coze/loop/apis/observability_open_apiservice.go 定义了三个开放接口:
POST /v1/loop/traces/ingest # Coze Loop 自有格式的 SDK 上报
POST /v1/loop/opentelemetry/v1/traces # 标准 OpenTelemetry 协议上报
POST /v1/loop/traces/search # OpenAPI 方式查询(给外部系统集成用)
IngestTraces 面向"用 Coze Loop 官方 SDK 埋点"的场景,格式贴合自己的领域模型;OtelIngestTraces(observability_open_apiservice.go:38-45)则直接接收业界标准的 OpenTelemetry 协议数据——这样任何已经用 OTel SDK 埋点的现有系统,都不需要改代码,只要把上报地址指向 Coze Loop 就能接入。这是"不重新发明轮子、兼容生态标准"的典型设计。Ingestion:像流水线一样处理海量 Span
不管从哪条路进来的 Span,最终都汇入同一套 Collector 流水线(backend/modules/observability/domain/trace/service/ingestion.go),三段式设计和很多可观测性系统(比如 OpenTelemetry Collector 本身)思路一致:
Receiver 接收器解析不同格式的上报数据(自有格式 / OTel 格式),统一转成内部 Span 结构Processor 处理器做清洗、脱敏、采样、限流等中间加工(可插拔链式处理)Exporter 导出器把处理完的数据写到最终存储,这里对应 clickhouseexporter/// ingestion.go: 三种 Factory 拼成一套可插拔流水线
type IngestionCollectorFactoryImpl struct {
receiverFactories []receiver.Factory
processorFactories []processor.Factory
exporterFactories []exporter.Factory
}
真正落库那一步,clickhouseexporter/clickhouse_exporter.go:30-51:
func (c *ckExporter) ConsumeTraces(ctx context.Context, td consumer.Traces) error {
tracesMap := make(map[loop_span.TTL]loop_span.SpanList)
for _, td := range td.TraceData {
ttl := td.TenantInfo.TTL // 按数据保留时长(TTL)分组
tracesMap[ttl] = append(tracesMap[ttl], td.SpanList...)
}
for ttl, spans := range tracesMap {
c.traceRepo.InsertSpans(ctx, &repo.InsertTraceParam{Spans: spans, Tenant: td.Tenant, TTL: ttl})
}
return nil
}
为什么落 ClickHouse,不是 MySQL
查询链路:四层往下传,一层比一层"脏"
前端点一下"查询 Trace",请求要经过四层,ListSpans 这个名字在每一层都出现,但含义逐层下沉:
application/trace.go:163TraceApplication.ListSpans——鉴权(CheckWorkspacePermission)、请求校验、DTO→内部请求结构转换domain/trace/service/trace_service.go:1296TraceServiceImpl.ListSpans——构建过滤条件(SpanEnv/FilterFields),处理业务规则(分页/权限相关的平台过滤)infra/repo/trace.go:205TraceRepoImpl.ListSpans——根据租户选存储后端(storageProvider.GetTraceStorage),拿到具体的 spanDaoinfra/repo/ck/spans.go最底层 DAO——真正拼 ClickHouse SQL、执行查询、把行数据反序列化回 Span 结构storageProvider.GetTraceStorage,而不是直连一个固定的 ClickHouse?
这是为多租户、多存储后端预留的抽象——不同工作空间可能配置了不同的 ClickHouse 集群(比如企业版允许自建存储),repo 层通过 StorageProvider 这一层间接寻址,domain 层完全不知道底层是哪个具体集群,这正是 AGENTS.md 强调的"domain 定义接口,infra 实现接口,domain 绝不引用 infra"的具体体现——从 ITraceService 到 ITraceRepo 都是接口,实现细节全部封装在 infra。👶 小白问:四层都叫 ListSpans,会不会读错层?
👨🏫 老师:这正是 DDD 分层的"统一语言"(Ubiquitous Language)思路——同一个业务概念在每一层都用同名方法表达,方便跨层追踪。读代码时看包路径(application / domain/.../service / infra/repo)比看方法名更重要,配合 IDE 的"跳转到定义"逐层往下点就不会迷路。
今日小结 + 动手 + 预告
🧠 今天你应该能回答
- Trace 和 Span 的关系是什么?靠哪个字段串成树?
- 为什么 Trace 有两条上报路径(自有格式 + OTel 标准协议)?
- Ingestion 的 Receiver/Processor/Exporter 三段各做什么?
- 为什么 Span 数据落 ClickHouse 而不是 MySQL?
- 查询链路的四层分别叫什么,各自的职责边界在哪?
✋ 动手 5 分钟(可选)
# 1. 看 Span 的完整字段定义
sed -n '157,190p' backend/modules/observability/domain/trace/entity/loop_span/span.go
# 2. 找到三条上报路由
grep -n "@router.*ingest\|@router.*opentelemetry" backend/api/handler/coze/loop/apis/observability_open_apiservice.go
# 3. 看 ClickHouse Exporter 怎么按 TTL 分组写入
sed -n '30,51p' backend/modules/observability/domain/trace/service/collector/exporter/clickhouseexporter/clickhouse_exporter.go
# 4. 沿着 ListSpans 逐层跳转(用 IDE 的"跳转到定义"体验更好)
grep -rn "func.*ListSpans(ctx" backend/modules/observability/
api/api.go 的 Init 函数,搞清楚 foundation→llm→prompt→data→evaluation↔observability 到底怎么被一行行代码组装起来的,认识 lodataset/lotrace 这类"本地客户端"的设计。