apps/server/docs/ai-context/verifications/langfuse-tracing.md
用户路径:客户端发 POST /api/v1/openai/chat/completions → 网关 handleCompletion 在处理时创建 Langfuse generation(input messages / output / model / token usage / userId / sessionId)→ 数据到达 Langfuse Cloud,可在逐条 prompt trace、eval、按用户/会话成本归因里查询。
apps/server/.env.local 的 DATABASE_URL(Neon)、REDIS_URL(Upstash)、OTEL_EXPORTER_OTLP_ENDPOINT(Grafana)全部指向生产实例。本地 pnpm dev 会连生产库并把 OTLP span 打到生产 Grafana,污染线上可观测性数据,也有触发真实计费的风险。因此用隔离 smoke 脚本复刻 instrumentation.ts + handleCompletion 的完全相同 wiring(独立 NodeTracerProvider + LangfuseSpanProcessor + shouldExportSpan langfuse.* 过滤 + setLangfuseTracerProvider + startObservation(asType:'generation') + langfuse.user.id/langfuse.session.id 属性),只验证 Langfuse 导出链路,不碰生产 DB/Redis/Grafana。生产路径的 generation 代码与 smoke 同形,typecheck 保证编译一致。
# 1. 凭据有效性
curl -u "$LANGFUSE_PUBLIC_KEY:$LANGFUSE_SECRET_KEY" https://us.cloud.langfuse.com/api/public/projects
# 2. smoke 脚本复刻 wiring,发一条 chat.completion generation 后 forceFlush + shutdown
pnpm exec dotenvx run -f .env.local -- tsx <smoke> # 临时脚本,验证后已删
# 3. 回读 Langfuse Cloud
curl -u "$PK:$SK" https://us.cloud.langfuse.com/api/public/traces?limit=20
curl -u "$PK:$SK" https://us.cloud.langfuse.com/api/public/observations?limit=20
类型 / 静态检查:
pnpm -F @proj-airi/server typecheck # tsc --noEmit,0 错误
pnpm exec eslint apps/server/instrumentation.ts apps/server/src/routes/openai/v1/index.ts # 0 warning/error
name=chat.completion,带 userId / sessionId(证明 langfuse.user.id/langfuse.session.id 提升为 trace 级归因)。type=GENERATION,带 model / input(messages 数组)/ output / usageDetails。status=200,project name=Airi id cmajdtoua06h2ad07yp1qf1nk。status=207,两 event 均 201 created —— 写入端点 + 凭据 + 格式全部正确。TRACE name=chat.completion user=smoke-user session=smoke-session (× 多条 smoke run)
TRACE name=direct-ingest-test user=direct-user
OBS_COUNT=20
OBS name=chat.completion type=GENERATION model=smoke-test-model
in=[{"role":"user","content":"ping from airi langfuse smoke (...)"}]
out="pong"
usage={"input":5,"output":1}
input messages / output / model / usageDetails / userId / sessionId 全部落到 Langfuse Cloud。shouldExportSpan langfuse.* 过滤未拒绝 generation span(smoke 用的就是该过滤,trace 正常出现)。
handleCompletion HTTP 请求验证(需生产隔离环境 + auth token + 配置好的 LLM_ROUTER_CONFIG 模型)。smoke 复刻同一 SDK 调用形态 + typecheck 编译一致 + lint 通过,作为当前可得的最强 fresh evidence。下一次在 staging(DB/Redis/Grafana 指向非生产)起真实 server 发一次 chat 请求即可补全端到端。codex 独立 review 报 8 项,修了 4 项(详见下方代码改动)。修复后复测:
tsc --noEmit 0 错误;eslint(instrumentation.ts + openai/v1/index.ts)0 输出。extractSseDeltaText + chunk 边界组装)4 case 全 PASS:简单多 delta、内容跨 chunk 断行、usage-only/空行忽略、malformed line 降级。AlwaysOnSampler(F4 修复)的 live smoke 再次回读成功:Langfuse Cloud observation model=verify-model,input=[{role:user,content:...}],output="Hello",usageDetails={input:5,output:2,total:6} —— 确认 provider sampler 改动没破坏导出。修复项:
@hono/otel HTTP span,默认 ParentBased sampler 会让 OTEL_TRACES_SAMPLING_RATIO<1 时连带丢 Langfuse generation。改 langfuseProvider 显式 AlwaysOnSampler,Langfuse 捕获与 Grafana head-sampling 解耦。response.json() 解析失败时 span + generation 都不 end。加 try/catch 在抛出前关闭两者。extractSseDeltaText 逐行解析出 assistant 正文,不再存 data: 框架。LANGFUSE_TRACING_ACTIVE sentinel(单一真相,防 enable 条件 desync 漏 PII)。把 Langfuse 逻辑从 transport 层 openai/v1/index.ts 抽到 services/domain/llm-tracing/index.ts(gate / SDK / SSE 解析 / 生命周期全部隐藏,route 只调 startChatGeneration → appendStreamChunk / succeed / fail)。复测:
pnpm -F @proj-airi/server typecheck:0 错误。pnpm exec vitest run .../llm-tracing/index.test.ts:Tests 9 passed (9) —— disabled no-op、创建参数、session 有无、非流式 output、流式跨 chunk 组装、malformed SSE 忽略、fail ERROR、幂等 end。纯逻辑单测,按 Iron Law 即该模块的 fresh evidence。pnpm exec eslint(instrumentation + route + 模块 + 测试):0 输出。补齐:
startTtsGeneration + /api/v1/audio/speech route:记录 tts.speech generation,不缓冲二进制 audio,只记录 input text/voice/speed/format、contentType、input char usage、flux metadata。packages/stage-ui/src/libs/providers/providers/official/shared.ts:official provider fetch 自动带 x-airi-session-id(Pinia active chat session 存在时)。复测:
pnpm -F @proj-airi/server typecheck:0 错误。pnpm exec vitest run apps/server/src/services/domain/llm-tracing/index.test.ts apps/server/src/services/domain/llm-router/tests/router.test.ts apps/server/src/routes/openai/v1/route.test.ts:3 files / 77 tests passed。pnpm exec eslint apps/server/instrumentation.ts apps/server/src/routes/openai/v1/index.ts apps/server/src/services/domain/llm-tracing/index.ts apps/server/src/services/domain/llm-tracing/index.test.ts apps/server/src/services/domain/llm-router/router.ts apps/server/src/services/domain/llm-router/tests/router.test.ts packages/stage-ui/src/libs/providers/providers/official/shared.ts:0 输出。pnpm -F @proj-airi/stage-ui typecheck:0 错误。Langfuse 隔离 live smoke(覆盖 NODE_ENV=codex-langfuse-smoke, OTEL_SERVICE_NAME=server-codex-langfuse-smoke, SERVER_INSTANCE_ID=codex-langfuse-smoke, 且清空 OTEL_EXPORTER_OTLP_ENDPOINT/OTEL_EXPORTER_OTLP_HEADERS)已补跑,只复用 .env.local 的 Langfuse keys,不连 DB/Redis/Grafana OTLP:
pnpm exec dotenvx run -f .env.local --ignore=MISSING_ENV_FILE -- tsx --import ./instrumentation.ts scripts/langfuse-smoke.tsOpenTelemetry initialized — OTLP: off, Langfuse: https://us.cloud.langfuse.comcodex-langfuse-1780140569403tts.speech, userId=codex-langfuse-smoke-user, sessionId=codex-langfuse-1780140569403-session, metadata.requestId=codex-langfuse-1780140569403-ttschat.completion, userId=codex-langfuse-smoke-user, sessionId=codex-langfuse-1780140569403-session, metadata.requestId=codex-langfuse-1780140569403-chattts.speech, type=GENERATION, model=codex-smoke-tts-model, usageDetails={input:38,total:38}, output.contentType=audio/mpegchat.completion, type=GENERATION, model=codex-smoke-chat-model, usageDetails={input:4,output:5,total:9}, output="hello from chat smoke"resourceAttributes 均为 service.name=server-codex-langfuse-smoke, service.namespace=airi, service.instance.id=codex-langfuse-smoke, deployment.environment=codex-langfuse-smoke。仍未做真实 server HTTP E2E:本地 .env.local 里的 DB/Redis 仍指向生产实例。当前已验证的是同一 instrumentation.ts + llm-tracing generation SDK 写入链路;真实 HTTP 请求还需要 staging DB/Redis/router/auth token 后补跑。
Langfuse Model costs 页面曾出现 chat-auto。这不是 Langfuse pricing 配置问题,而是 route 在调用 llmRouter.route(...) 前就用 client/request model 创建 chat.completion generation;如果 router config 通过 upstream.overrideModel 把 chat-auto 改写成真实上游模型,Langfuse 仍记录 alias。
修正:
LlmRouteContext.upstreamModel:router 成功命中上游时写入实际发给上游的 overrideModel ?? modelName。handleCompletion:router 返回后再创建 Langfuse generation,model 使用 routeCtx.upstreamModel ?? requestModel。requestModel 语义;本次只修 Langfuse model-cost 归因。复测:
apps/server/src/services/domain/llm-router/tests/router.test.ts:覆盖 upstream.overrideModel 同时写入 ctx.upstreamModel。apps/server/src/routes/openai/v1/route.test.ts:覆盖请求 model=chat-auto、router context 返回 openai/gpt-4o-mini 时,startChatGeneration({ model }) 使用 openai/gpt-4o-mini。pnpm exec vitest run apps/server/src/services/domain/llm-router/tests/router.test.ts apps/server/src/routes/openai/v1/route.test.ts apps/server/src/services/domain/llm-tracing/index.test.ts:3 files / 78 tests passed。pnpm -F @proj-airi/server typecheck:0 错误。pnpm exec eslint apps/server/src/routes/openai/v1/index.ts apps/server/src/routes/openai/v1/route.test.ts apps/server/src/services/domain/llm-router/router.ts apps/server/src/services/domain/llm-router/types.ts apps/server/src/services/domain/llm-router/tests/router.test.ts:0 输出。dc1037f34(本次改动未提交,工作树状态)@langfuse/tracing + @langfuse/otel 5.4.0,@opentelemetry/api 1.9.1