Langfuse 集成源码:batch 协议、media 上传与 mock 测试(第89篇-E75)

0 阅读10分钟

上一篇站在使用者视角把行车记录仪装上了:handler 翻译切面、队列攒批、四道工序。这篇下到桥接层最底下——libs/acl/langfuse 这个不含任何 Eino 概念的纯 HTTP 客户端库,回答三个出门前才需要想的问题:

  1. 一批事件出门时,线上到底跑的是什么协议?(batch ingestion)
  2. 用户发了张 base64 图片,怎么不把管道撑爆?(media 三步上传)
  3. 这套东西不发真请求怎么测?(两层 mock)

(一)batch ingestion:一个 POST 的三级响应

协议本身简单到一句话:POST {host}/api/public/ingestion,Basic 认证(pk:sk base64),body 是 {"batch": [事件数组], "metadata": {批次信息}}。值得看的是两边的设计。

事件信封是"类型字段 + 联合体"。 每个事件固定五件套 {id, type, timestamp, metadata, body}type 是上一篇讲过的八种(trace-create / span-create / …),body 是五选一的联合体:

type eventBodyUnion struct {
	Trace      *TraceEventBody      `json:",inline,omitempty"`
	Span       *SpanEventBody       `json:",inline,omitempty"`
	Generation *GenerationEventBody `json:",inline,omitempty"`
	Event      *EventEventBody      `json:",inline,omitempty"`
	Log        *SDKLogEventBody     `json:",inline,omitempty"`
}

Go 没有原生 union,这里的做法是五个指针字段 + 手写 MarshalJSON 按优先级序列化非空的那个(event.go:85-96)。事件类型在信封上、字段形状在 body 里——这也是 Langfuse 服务端能按 ID upsert 的前提:span-createspan-update 带同一个 observation ID,谁先到都能落库。

响应分三级处理client.go:134-150):

状态码含义客户端行为
200 / 201全部成功直接返回 nil
207 Multi-Status部分成功解析 errors 数组,聚合成 apiErrors 返回
其他全部失败包成 *apiError{Status, 原始body} 返回

207 是批量接口特有的语义:一批 15 条事件,14 条落库 1 条被拒(比如单字段超限),服务端不会整体回滚,而是告诉你哪条死了。合理,但和上一篇的重试分类碰在一起有个细节:重试判定用 errors.As(err, &apiError) 匹配 *apiError,而 207 聚合出来的是 apiErrors(切片类型)——类型不匹配,不会被判成永久失败,于是整批走重试。重试会把已成功的 14 条再发一遍。好在 ingestion 按 ID upsert,幂等兜住了,代价只是白传。这是个"两个各自合理的设计拼在一起产生冗余"的典型样本。

一个逐字段核对才会发现的坑:批次 metadata 的组装(consumer.go:284-290)里,SDK 标识三个字段的值交叉了:

"sdk_integration": i.sdkIntegration, // "eino"   ✓
"sdk_name":        i.sdkVersion,     // "v0.0.1" ✗ 应为 "Golang"
"sdk_version":     i.sdkIntegration, // "eino"   ✗ 应为 "v0.0.1"

对照常量定义(langfuse.go:27-29sdkName="Golang"sdkIntegration="eino"sdkVersion="v0.0.1"),sdk_name 填进了 version 的值、sdk_version 填进了 integration 的值。只影响 Langfuse 界面上 SDK 标识的展示,不影响数据,属于 LOW 级别缺陷——但它是真实存在的,demo 场景 A 把实际出门的 metadata 打了出来:

====== 场景 A:ingestion 响应三级分类 + 出门 metadata ======
  批 1200 OK           :成功,无错误
  批 2207 Multi-Status :部分失败 API errors: api error 400: id=e2: field size exceeded
        isPermanent(apiErrors)=false —— 类型不匹配 *apiError,会走重试
  批 3500              :api error 500: 
        isPermanent=false5xx → 重试;若换成 400/401 则=true,吞掉不重试)
  实际出门的批次 metadata:
    {"batch_size":"3","public_key":"pk-lf-demo","sdk_integration":"eino","sdk_name":"v0.0.1","sdk_version":"eino"}
    ↑ sdk_name="v0.0.1" sdk_version="eino" —— consumer.go:287-288 两行值交叉(真实缺陷)

demo(/tmp/e89demo,纯标准库 + httptest 本地回环)把 client 的三级分类照抄了一遍,metadata 组装逐字照抄源码——所以最后一行不是 demo 的 bug,是源码 bug 的实锤复现。

(二)media 三步上传:base64 图片的旁门

先说为什么需要这个旁门。多模态消息里图片常以 data URI 内联(data:image/png;base64,....),base64 编码膨胀 4/3:一张 500KB 的图变 667KB 字符串。上一篇讲过单事件上限 MaxEventSizeBytes=1MB、超了从最大字段整字段清空——两三张图就会把 input 整个清成 (truncated),回放界面上图片和文字一起消失。

所以消费侧的处理顺序是媒体工序排第一(采样 → 媒体 → 脱敏 → 截断):先把图片从消息里摘出去传成 media,替换成一个几十字节的占位符,再轮到截断时,事件已经瘦身了。

摘取tryNewMediaFromBase64media.go:36-68)只认 data: 前缀的 URL——http(s) 链接本来就是服务端可拉取的,原样保留;解析 data URI 的头(contentType + base64 标记)和负载,解码出原始字节,顺手算 sha256。这个 hash 是后面的去重钥匙。

三步上传consumer.go:392-446):

① POST /api/public/media          问服务端要上传地址
     请求带 {traceId, observationId, contentType, contentLength, sha256Hash, field}
     响应给 {mediaId, uploadUrl}
② PUT  {uploadUrl}                直传对象存储(URL 是服务端签发的预签名地址)
     头里同时带 x-amz-checksum-sha256(S3 风格)和 x-ms-blob-type: BlockBlob(Azure 风格)
③ PATCH /api/public/media/{id}    上报结果 {uploadedAt, uploadHttpStatus, uploadTimeMs}

三个设计点:

  1. ①是同步的,②③是异步的。 步骤①在 consumer goroutine 里做(要拿到 mediaId 才能构造占位符,慢不得);②③塞进独立 goroutine(mediaWG.Add(1) + defer Done() + recover() 兜底),不阻塞事件批次出门。而 flush() 的完整语义是 q.join() + mediaWG.Wait()task.go:71-74)——上一篇说"flusher 不调丢最后一批",其实它还等你没传完的图。
  2. 上传成功失败都要 PATCH。 步骤③不管 ② 的状态码是几都执行,把 uploadHttpStatus 记到服务端——Langfuse 界面上 media 显示"上传失败"而不是无限转圈,靠的就是这个"失败也汇报"。
  3. sha256 是服务端去重钥匙。 步骤①把 hash 一起报上去,服务端发现同一内容已存在时返回空的 uploadUrl,客户端直接复用 mediaId、跳过②③。同一张图被三个节点引用(prompt 渲染、重试、缓存命中),只传一次。

出门的消息长这样:图片 URL 被替换成占位符 @@@langfuseMedia:type=image/png|id=media-1|source=base64_data_uri@@@,Langfuse 前端渲染时把它还原成 media 引用。而原消息一个字节都没动——convMedias 对每条消息做浅拷贝(consumer.go:328-332:复制 Message 结构体 + 复制 MultiContent 切片),在副本上改 URL。这不是洁癖:handler 拿到的 InMessages 可能还被业务侧持有,观测层改写共享数据等于埋并发雷。

demo 场景 B/C/D 依次验证这三点:

====== 场景 B:base64 图片 → 三步上传 + 占位符替换 ======
  原消息: text + dataURI(解码 16 字节) + httpURL
    服务端收到: POST /api/public/media
    服务端收到: PUT /obj/media-1
    服务端收到: PATCH /api/public/media/media-1
  出门消息的图片 URL: "@@@langfuseMedia:type=image/png|id=media-1|source=base64_data_uri@@@"
  http URL 原样保留:  "https://cdn.example.com/http-only.png"(非 data: 前缀,不走媒体通道)
  原消息的图片 URL 仍是 data: 前缀("data:image/png;base6"…)—— shallow copy 未动原消息

====== 场景 C:同内容 hash 去重(第二次零 PUT)======
  同一张图传两次:mediaID m1=media-1 m2=media-1(服务端按 sha256 判重复用)
  PUT 次数=1(第二次 getUploadURL 返回空 uploadUrl,直接复用)

====== 场景 D:媒体异步上传,flush 会等它 ======
  PUT 完成、PATCH 已到服务端但被 gate 挡住: patchDone=false
  flush() 阻塞中 —— 它在等 mediaWG(媒体 goroutine 没结束)
  放行后 flush() 返回,PATCH 已完成: true

场景 D 用一个 gate channel 卡住假服务器的 PATCH handler:flush 被验证为确实在等媒体 goroutine——放行前阻塞、放行后返回,这行输出就是 flush = q.join() + mediaWG.Wait() 的行为证明。

还有一个容易漏看的序列化细节:GenerationEventBodyInMessages/OutMessage 标的是 json:"-"event.go:260-261)——消息对象不直接进 JSON,消费侧统一 marshal 成字符串塞进 Input/Output 字段。原因是 schema.Message 自身的 JSON 形状不适合直接当观测数据(多模态时 Content 与 MultiContent 的表达冲突),mediaMessage 结构(media.go:78-87)把 MultiContent 提为 content 字段做了一次适配。观测层的数据形状由观测层定义,不迁就业务模型。

(三)mock 测试:两层桩,三个角度

这套集成怎么在不连 Langfuse 的情况下测?源码里有两层 mock 路径:

传输级libs/acl/langfuse/langfuse_test.go:34-46):用字节的 mockey 库直接打桩 (*http.Client).Do,按请求 path 分流返回假响应——getUploadURLPath 返回签好的 URL、ingestionPath 返回空成功。HTTP 一个字节都没出门,但 client 的完整代码路径(构造请求、解析响应、三级分类)全跑到了。

接口级callbacks/langfuse/langfuse_test.go):ACL 层导出了 Langfuse 接口(七方法)+ gomock 生成的 MockLangfuse。但 handler 内部自己调 NewLangfuse 构造真实现,外部注入不进去——测试的组合拳是 mockey.Mock(langfuse.NewLangfuse).Return(mockLangfuse)把构造函数整个换成返回 mock。接口 mock 提供可断言的桩,函数打桩解决"接线在内部"的注入问题。

接口级测试里有三个值得抄的姿势:

  1. 用调用顺序验证树结构。 CreateSpanEXPECT().DoAndReturn 里数调用次数:第 0 次必须没有 ParentObservationID(图级 span),第 1、2 次的 parent 必须是图级 span 的 ID——上一篇"图也是 span、树靠 parent 连"的结论,在这个测试里是被逐调用断言钉死的。
  2. 流式用 Pipe 手工喂 chunk。 测试不真起模型,schema.Pipe Send 三个 CallbackInputClose(),模拟流式输入的分片到达,验证 handler 的流式拼接逻辑。
  3. 两个 Attack 测试守住边界。 TestAttack_NilMessageInOnEnd:OnEnd 的 Message 为 nil 时 Usage 必须为 nil(防 panic、防假数据);TestAttack_ExtractModelOutputErrorIgnored:输出拼接失败时 EndGeneration 只允许调一次——注释直说了这是流式双 goroutine 路径的坑。命名带 Attack 的测试是拿来防"恶意/畸形输入"的,这两条正是上篇"流式走另一条 goroutine"的回归防线。

demo 的 fakeServer(httptest + 状态码脚本 + 调用序列记录 + gate)本质上就是传输级 mock 的同构——不需要 mockey,标准库就够。

小结

问题答案关键源码
协议长什么样一个 POST,信封{type, body联合体},Basic 认证client.go:100-151
部分失败怎么办207 + errors 数组;但 apiErrors 不匹配重试分类 → 整批重试,upsert 幂等兜底client.go:136-149
SDK 标识对吗不对:sdk_name/sdk_version 值交叉(LOW,仅影响展示)consumer.go:287-288
图片怎么不撑爆管道先摘成 media 再截断;占位符替换;原消息不动media.go + consumer.go:324-446
图片传几次sha256 去重,同内容一次;失败也 PATCH 上报consumer.go:411-413
flush 等不等图片等:q.join() + mediaWG.Wait()task.go:71-74
不联网怎么测传输级 mockey 打桩 http.Do;接口级 gomock + 打桩构造函数两个 _test.go

几条设计判断:

  • 批量接口要有 207 语义,但客户端的重试分类要认得它。 "部分成功"聚合成集合类型后逃过了 *apiError 的判定,属于类型设计上的缝隙——幂等 upsert 救了它,但"靠下游幂等兜底"应该是有意为之的设计,不是侥幸碰上。
  • 改写共享数据前先拷贝是观测层的铁律。哪怕只是换个 URL 字段,浅拷贝的成本可以忽略,并发雷的排查成本不能。
  • 失败也要汇报(PATCH 上报失败状态)是可观测系统的自反性:观测工具自己出了问题,也应该被观测到。
  • 去重键选内容 hash 而不是路径/ID:同一张图在 trace 里的不同位置、不同字段(input/output/metadata),天然共享一个 mediaId。

下一篇离开可观测,回到安全:日志和观测数据里不能出现身份证号——PII 脱敏的正则方案 + Hook 链,上一篇埋的 MaskFunc 管道挂点在那里接上完整方案。