Cherry Studio 可观测性体系解析:基于 OpenTelemetry 的 AI 调用与 Agent 运行时链路追踪实现 📅 发布时间:2026/9/20 15:47:27 👁 浏览次数: Cherry Studio 可观测性体系解析基于 OpenTelemetry 的 AI 调用与 Agent 运行时链路追踪实现【免费下载链接】cherry-studio Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio本文系统讲解 Cherry Studio 桌面客户端中src/main/ai/observability/子系统它如何利用 OpenTelemetryOTel对 AI SDK 调用、Agent 运行时与工具执行进行全链路追踪如何在主进程构建本地 span 投影、按主题topic将链路历史落盘为 JSONL 文件以及如何通过 sink 注册表为本地与未来的外部导出提供统一扩展点。读完本文你将掌握该项目的追踪数据流从 span 产生、转换、投影到持久化与渲染端查看、开发者模式的开关机制以及其中本地落盘、明文捕获、不做脱敏等关键工程取舍的源码级依据。一、子系统总览一条链路从产生到查看的完整路径Cherry Studio 的Trace / 遥测功能面向用户呈现为一条可查看的链路trace但其底层是一整个位于主进程的子系统。按 src/main/ai/observability/ 的目录结构它被划分为五个职责清晰的模块目录职责core/创建 Cherry 自有的回合根 spanAiTurnTrace与公共cs.*属性负责ReadableSpan→SpanEntity的通用转换adapters/aiSdk/解释 AI SDK 自动生成的子 spanAdapterTracerAiSdkSpanAdapteradapters/claudeCode/解释 Claude Code 通过 OTLP 上报的 span 与日志本地 OTLP 接收端 适配器storage/内存 span 投影TraceStorageService与 JSONL 兼容的历史文件读写sinks/定义观测 sink 扩展点ObservabilitySink与注册表ObservabilitySinkRegistry主进程侧的运行时接线runtime/则由NodeTraceService负责它用CacheBatchSpanProcessorFunctionSpanExporter把 OTel SDK 的 span 处理管线接到TraceStorageService上NodeTraceService.ts。整个子系统通过 index.ts 统一对外导出。值得注意的一个设计事实是采集与持久化只发生在主进程。渲染进程的链路查看器TracePage从不自己采集 span而是通过trace:getDataIPC 按需读取主进程已持久化的数据IPC 通道定义见 src/shared/IpcChannel.ts。二、插桩范围AI SDK 调用产生的一棵 span 树当开发者模式开启且存在 topic 级 trace 上下文时一次经由 Cherry Studio 发起的 AI SDK 调用会产生一棵如下的 OpenTelemetry span 树chat.turn (root由 context provider 创建) ├── ai.streamText (AI SDK 自动) │ ├── ai.streamText.doStream (AI SDK 自动) │ ├── ai.toolCall (每次工具调用一个) (AI SDK 自动) │ └── ai.streamText.step (AI SDK 自动) └── attributes: topicId, modelName, … (由 AiTurnTrace / AdapterTracer 写入)2.1 根 spanCherry 自有而非 AI SDK 生成AI SDK 的experimental_telemetry负责产生上述内层 span而根 span 由 Cherry Studio 自己拥有——通过AiTurnTrace创建从而让整棵链路进入同一套可观测性管道而不必经由 AI SDK 适配器AiTurnTrace.ts。startAiTurnTrace返回一个AiTurnTraceHandle携带traceId、rootSpanId、rootSpan以及两个关键方法addEvent(name, attributes)向根 span 追加事件end(status, error)按ok/aborted/error三种状态结束根 span错误时记录异常对象AiTurnTrace.ts。对普通聊天回合根 span 名为chat.turn由 context provider 创建对 Agent 会话则由AgentSessionRuntimeService驱动。根 span 上会写入trace.topicId、trace.modelName等通用属性回合边界内容触发回合的用户提示与最终回答则由 turnSpanAttributes.ts 中的applyTurnInputAttributes/applyTurnOutputAttributes以 OTel GenAI 语义约定写入gen_ai.operation.namechat普通回合或invoke_agentAgent 会话gen_ai.conversation.id即topicIdgen_ai.provider.name/gen_ai.request.model由模型 ID 解析而来gen_ai.agent.nameAgent 会话时写入展示名inputs最后一条含文本的用户消息截断上限MAX_TURN_INPUT_CHARS 8KBoutputs最终回答文本截断上限MAX_TURN_OUTPUT_CHARS 512KB与 HTTP trace 的 body 上限对齐保证长流式响应能完整保留。2.2 根 span 的结束补丁与 sink 注册表对接与AdapterTracer类似AiTurnTrace也会对根 span 的end()打补丁span.end被包装后除调用原始end()外还会通过convertSpanToSpanEntityspanConvert.ts把根 span 转为SpanEntity并写入 sink 注册表。该转换函数同时被TraceStorageService复用保证 Cherry 自有根 span 与 AI SDK 子 span 使用一致的SpanEntity形态与毫秒级时间表示spanConvert.ts。convertSpanToSpanEntity还有一个值得注意的细节isEnd字段直接从 OTel 的ended标志推导spanConvert.ts而进行中的 span 由存储层在createSpan时显式覆盖为false——这保证了 trace 的旧数据可被正确回收。三、本地历史落盘从内存投影到 JSONL 文件容器topic / agent-session的traceId会持久化在 topic 行或 agent-session 行上一个容器对应一棵 trace 树但 span 树本身先收集在主进程TraceStorageService的内存存储中再在流终止时落盘为持久化历史文件。3.1 TraceFlushListener流终止时触发落盘历史文件的写入由流的终止路径驱动核心是 TraceFlushListenerPersistentChatContextProvider 为普通聊天回合挂载TraceFlushListenerAgentSessionRuntimeService 为agent-session:${sessionId}回合挂载同一监听器包括排队的后续回合L548、L2695 等多处挂载点在 topic 级终止事件done、paused或error上TraceFlushListener调用TraceStorageService.saveSpans(topicId)TraceFlushListener.ts落盘失败只记录 warning不影响消息完成的正常流程。saveSpans(topicId)会查询该 topic 下所有 traceId 并逐个执行flushTraceTraceStorageService.ts把内存中该 trace 的 span 追加写入 JSONL 文件然后只清除恰好已写盘的那些 spanTraceStorageService.ts——尚未登记 topicId 的 span 会保留在内存中待 topicId 注册后再次 flush避免未写即丢。3.2 存储位置与兼容性说明Trace 历史文件存储于{userData}/Runtime/trace/topicId/traceId。此前的~/.cherrystudio/trace路径不再被写入仅作为normal_cacheApp 缓存清理项的遗留清理目标。写入采用临时文件 原子重命名策略TraceStorageService.ts先写${filePath}.${pid}.tmp成功后fs.rename覆盖原文件避免中途崩溃截断历史同时逐行流式读取旧文件、按 span id 去重合并而不是把整份 trace 解析成多份大内存副本。文件权限上目录以0o700创建、文件以0o600创建——因为 trace JSONL 按设计保存了开发者模式下的明文 prompt / API body详见第六节。3.3 内存与文件的保留上限Claude Code 的 OTLP 日志事件在回合进行中约每 1 秒到达一次但事件引用的 span 只有在结束时才导出因此TraceStorageService实现了孤儿事件缓冲机制并在多个维度设置了硬性上限TraceStorageService.ts常量上限作用MAX_PENDING_EVENT_SPANS1000最多同时缓冲的孤儿事件所属 span 数超出按插入顺序淘汰最旧者MAX_PENDING_EVENTS_PER_SPAN200单个孤儿 span 缓冲的事件条数上限MAX_PENDING_EVENT_BYTES16 MiB全部孤儿事件的总字节预算一次 OTLP 请求解压后可达约 10 MiB 明文条数上限不足以约束内存MAX_SPAN_EVENTS200单个已存储 span 保留的事件条数上限实测单 span 可达约 1.5k 事件 / 20 MiBMAX_SPAN_EVENT_BYTES2 MiB单个 span 保留事件的字节预算保证单个 span 不会独占文件预算MAX_TRACE_FILE_BYTES8 MiB单条 trace 历史文件保留字节数超出时丢弃最旧 span软上限本次 flush 正在写入的 span 总是保留这些上限共同保证了即使一个 span 永远不结束、或一条长会话持续向同一文件追加内存与磁盘都不会无界增长查看器每次重新同步也不至于把整条巨大链路反复解析进主进程堆、IPC 载荷和渲染端节点图。3.4 按需读取与光标式增量查看TraceStorageService对外暴露两条 IPCTraceStorageService.tsTRACE_GET_DATA trace:getData渲染端按(topicId, traceId, cursor)读取TRACE_CLEAN_LOCAL_DATA trace:cleanLocalData清理本地 trace 数据清空内存存储并递归删除 trace 根目录。getTraceData采用光标式增量返回TraceStorageService.ts历史文件只在文件变化historyVersion由mtimeNs:size计算时才整体发送否则只返回自上次liveRevision以来内存中的变更 span。返回结构TraceDataResultsrc/shared/data/types/trace.ts中的reset标志告诉查看器是重置整个快照还是增量应用变更。这样热轮询路径TracePage以 1 秒间隔轮询进行中的 trace、空闲时降为 5 秒不必每次重新解析和克隆整份 trace。查询与落盘时的topicId/traceId来自渲染端 IPC会直接拼入fs.rm/readFile路径因此TraceStorageService内置了assertSafeSegment校验TraceStorageService.ts拒绝空值、.、..、含/或\的段、绝对路径防止../../../etc这类值把删除/读取引到 trace 根目录之外该路径可达性可经 XSS 枢轴触发。3.5 容器 trace 的合成根 span每个容器topic / agent-session对应一个由 traceId 确定性派生的合成根 span idderiveRootSpanId(traceId)取 traceId 前 16 位十六进制作为 span id若全零则回退为固定值src/shared/data/types/trace.ts。该函数放在shared层是因为主进程的 trace 生产者与渲染端查看器都必须对容器根 id 达成一致——渲染端会把预热中的子进程 span重新归置到拥有它的回合之下。startAiChildTurnSpan也利用这一点同一 topic/session 的每个回合都挂在同一个 traceId 的合成根下从而形成一棵贯穿多次回合的 trace 树AiTurnTrace.ts。四、AdapterTracer为 AI SDK 子 span 定制的 OTel Tracer 包装adapterTracer.ts 包装全局 provider 返回的 OTelTracer只服务于 AI SDK 子 span。其机制是在每次startSpan/startActiveSpan时包装返回的 span做两件事adapterTracer.ts补丁span.end()先调用原始end()再调用AiSdkSpanAdapter.convertToSpanEntity(...)并把结果交给观测 sink 注册表observabilitySinks.writeSpanEntity转换失败只记录 warning不抛出打标trace.topicId与trace.modelName让主进程侧TraceStorageService能按 topic 对 span 建索引。startActiveSpan则通过查找回调参数位置、包装回调中收到的 span 来实现同样效果adapterTracer.ts。AdapterTracer的生产者是 buildTelemetry文档中原路径为runtime/aiSdk/params/buildTelemetry.ts属于 AI 运行时而非 observability 目录它把AdapterTracer作为experimental_telemetry.tracer传给 AI SDK从而捕获每一个 AI SDK 自动 span。关键门控逻辑是buildTelemetry.ts若请求上下文没有topicId→ 返回undefined无遥测若开发者模式未开启→ 返回undefined只有两者都满足时才返回TelemetrySettings并把providerId、modelId、topicId、modelName以及trace.topicId/trace.modelName一并写入 metadata。换句话说开发者模式关闭时根本没有 tracer 被挂载AI SDK 不产生任何 span——这是第六节将详述的开发者模式门控的第一道闸。五、AiSdkSpanAdapter把 OTel span 转换为 SpanEntityaiSdkSpanAdapter.ts 负责把 OTel span 转换成TraceStorageService存储与持久化的SpanEntity形态类型定义见 src/shared/data/types/trace.ts。SpanEntity包含id、parentId、traceId、name、status、kind、attributes、isEnd、events、startTime、endTime、links以及可选的topicId、usage、modelName。5.1 多版本 SDK 兼容读取由于 AI SDK / OTel SDK 不同版本内部字段名不同v1 的parentSpanId字符串在 v2 被移除改为parentSpanContext适配器按_attributes→getAttributes()→attributes→_spanData.attributes的优先级读取属性并同时兼容parentSpanContext.spanId与注入的trace.parentSpanId属性来解析父 spanaiSdkSpanAdapter.ts。这段兼容逻辑很重要只读旧字段会让所有 AI SDK span 的 parent 为空ai.streamText等会错误地渲染成 trace 根而不是嵌套在ai.turn之下。5.2 属性层级约定与按操作类型的输入输出提取适配器恢复 AI SDK 的层级属性约定ai.xxx是一层ai.xxx.yyy是它的子层。AiSdkSpanAdapter依据ai.operationId精确映射输入/输出aiSdkSpanAdapter.tsoperationId输入来源输出来源ai.generateText/ai.streamTextai.promptai.response.text/ai.response.toolCalls/ai.response.finishReason/ai.settings.maxOutputTokensai.generateText.doGenerate/ai.streamText.doStreamai.prompt.messages/ai.prompt.tools/ai.prompt.toolChoice文本与工具调用外加ai.response.msToFirstChunk/msToFinish/avgCompletionTokensPerSecond等性能指标ai.toolCallai.toolCall.name/id/argsai.toolCall.result其他通用兜底键ai.prompt、ai.prompt.messages、ai.request、inputs…通用兜底键ai.response.text、ai.response、ai.output、outputs…字符串形式的 JSON 属性值会被尝试解析parseAttributeValue便于查看器直接展示结构化对象。extractSpanTag还会按 operationId 把 span 分类为LLM-GENERATE、LLM-STREAM、PROVIDER-GENERATE、PROVIDER-STREAM、TOOL-CALL、IMAGE、EMBEDDING等标签aiSdkSpanAdapter.ts供渲染端聚合与过滤。5.3 Token 用量归一化extractTokenUsage在 base span 与 LLM span 之间归一化用量属性优先采用 AI SDK v6 的键名并依次回退到旧别名与语义约定拼写aiSdkSpanAdapter.ts输入ai.usage.inputTokens→ai.usage.promptTokens→gen_ai.usage.input_tokensembeddings 场景读取单值ai.usage.tokens输出ai.usage.outputTokens→ai.usage.completionTokens→gen_ai.usage.output_tokens总量ai.usage.totalTokens缺省时由输入 输出求和优先采用 SDK 报告的总量因为它已计入推理/缓存 token细分ai.usage.cachedInputTokens落入prompt_tokens_details.cached_tokensai.usage.reasoningTokens落入completion_tokens_details.reasoning_tokens。归一化后的TokenUsage契约保持 snake_caseprompt_tokens/completion_tokens/total_tokens见 src/shared/data/types/trace.ts。没有用量属性的 span如纯工具调用返回undefined属正常预期。六、Sink 注册表本地投影与未来外部导出的统一扩展点所有 span 最终都要经过 ObservabilitySinkRegistry注册表持有Mapid, ObservabilitySink默认注册唯一的本地 sinklocalTraceWindow并通过registerTraceMeta/writeReadableSpans/writeSpanEntity/writeSpanEvent/writeRawOtlpPayload五个操作把数据分发到每个 sink单个 sink 任一步骤失败都被隔离为 warning绝不向上抛出ObservabilitySinkRegistry.ts。sink接口ObservabilitySink.ts设计为全部可选方法因此接入方只需实现自己关心的入口writeRawOtlpPayload的路径参数限定为/v1/traces | /v1/logs为外部导出预留了 OTLP 原始载荷透传通道。本地实现 LocalTraceWindowSink 只是一个薄壳把三个写入口分别转发到TraceStorageService.setTopicId/saveEntity/addSpanEvent。也就是说sink是架构上的扩展点本地投影是它的第一个消费者——未来要接入外部导出如远程 OTLP collector只需注册新 sink 即可无需改动 span 产生侧。七、非 AI SDK 运行时的链路接入Claude Code、Pi 与 DSHAI SDK 之外的运行时并不都具备原生 OTel 导出能力Cherry Studio 为它们分别采用了不同的接入策略。7.1 Claude Code本地 OTLP 接收端Claude Code Agent SDK 的 span 不经过AiSdkSpanAdapter而是由 ClaudeCodeOtlpAdapter.ts 转换。链路结构上由 ClaudeCodeTraceBridgeService 负责本地 OTLP collector在主进程内createServer监听127.0.0.1的随机端口server.listen(0, 127.0.0.1)提供POST /v1/traces与POST /v1/logs两个端点ClaudeCodeTraceBridgeService.ts环境变量注入prepareTrace(context)返回一组注入给 Claude Code 子进程的环境变量ClaudeCodeTraceBridgeService.ts包括CLAUDE_CODE_ENABLE_TELEMETRY1、OTEL_TRACES_EXPORTERotlp、两个导出端点、OTEL_TRACES_EXPORT_INTERVAL1000以及OTEL_LOG_USER_PROMPTS/OTEL_LOG_TOOL_DETAILS/OTEL_LOG_TOOL_CONTENT/OTEL_LOG_RAW_API_BODIES四个详细日志开关并通过TRACEPARENT把容器 trace 上下文00-${traceId}-${rootSpanId}-01传递给子进程使子进程的 span 落入同一棵容器 trace上下文登记与刷新trace 上下文按 traceId 登记并带 30 分钟 TTLTRACE_CONTEXT_TTL_MS 30 * 60 * 1000回合之间可refreshTraceContext刷新单次请求体上限 10 MiBMAX_BODY_BYTES。OTLP span 与日志记录在 ClaudeCodeOtlpAdapter.ts 中被转换为SpanEntity与TimedEvent日志事件以claude_code.log.severity命名、携带otel.signal标记并保留log.body。由于日志事件先于其 span 到达见 3.3 节它们会先进入孤儿缓冲待 span 落地后被drainPendingEvents冲刷到对应 span 上。7.2 PiCherry 自有的运行时 spanPi 没有原生 OTel exporter其运行时连接在provider 流边界创建 Cherry 自有的pi.generate_contentspanSpanKind.CLIENT并从 Pi 的工具生命周期事件创建pi.execute_toolspanPiRuntimeConnection.ts。这些 span使用宿主提供的 agent-session trace 上下文startAgentRuntimeChildSpan见 AiTurnTrace.ts自动继承cs.agent_session_id/cs.agent_turn_id等公共属性携带gen_ai.operation.name、gen_ai.provider.name、gen_ai.request.model等 GenAI 语义属性完成后回填gen_ai.response.*与gen_ai.usage.*含 cache read/write、reasoning token 细分并行工具调用按 tool-call id 追踪连接结束时未结束的 span 被统一关闭endAgentRuntimeSpan保证追踪失败不会逃逸进回合流程。它们流经既有的NodeTraceService与TraceStorageService管道与 AI SDK / Claude Code span 共用同一投影与落盘路径。7.3 DSH同样的自有 span 策略DSH 同样不使用外部 OTLP 适配器而是由DshTraceRecorder在 agent-session trace 根下记录dsh.generate_content、工具、压缩compaction与子运行时 span见 DshRuntimeConnection.ts 及其dshTrace.ts配套实现。它的行为与 Pi 类似回合之间刷新 trace 上下文、连接结束时关闭未完成 span相关行为有 DshRuntimeConnection.trace.test.ts 等测试用例佐证如断言 span 名为dsh.generate_content。八、敏感数据捕获与脱敏决策本地明文、不做脱敏这是整个子系统最需要使用者知晓的工程取舍。Claude Code OTLP bridge仅在开发者模式开启时运行一旦运行它会刻意打开 Claude Code 的详细遥测ClaudeCodeTraceBridgeService.tsOTEL_LOG_USER_PROMPTS— 用户提示文本OTEL_LOG_TOOL_DETAILS/OTEL_LOG_TOOL_CONTENT— 工具调用及其内容OTEL_LOG_RAW_API_BODIES— 原始 API 请求/响应体。这些载荷会落入 span 属性并被TraceStorageService作为明文 JSONL trace 文件持久化到磁盘——因此一条 trace 可能同时包含密钥授权头、内嵌在原始 body 中的 API key与提示词、工具内容。脱敏被刻意不做。原因有二文档明确记录源码注释亦复述其一在摄取路径上剥离密钥意味着要解析任意 OTLP 属性结构风险是误删合法 trace 数据其二这是被接受的取舍——捕获仅限本地、且受开发者模式门控把它升级为脱敏/威胁模型保证是延迟决策。因此本文建议将导出的 trace 文件视为敏感数据对待。从实现上也能看到相应措施目录0o700、文件0o600的权限设置TraceStorageService.ts以及清理本地数据失败时必须向上抛出、不让调用方误报成功TraceStorageService.ts。九、开发者模式门控整条链路的总开关整个 trace 子系统是仅开发者模式可用的。三道闸门层层把关无 tracer 挂载开发者模式关闭时buildTelemetry返回undefinedAI SDK 不产生任何 spanbuildTelemetry.ts服务不激活TraceStorageService、NodeTraceService、ClaudeCodeTraceBridgeService三个服务都在onReady阶段读取app.developer_mode.enabled偏好关闭则跳过activate()如 TraceStorageService.ts、NodeTraceService.ts开发者模式的偏好变更需重启应用生效不支持运行时激活/去激活渲染端空视图由于没有 span 可投影TracePage查看器显示空 trace。此外NodeTraceService对 OTel 重模块NodeTracerProvider、BatchSpanProcessor、OTLPTraceExporter等采用dynamic import()延迟加载NodeTraceService.ts确保开发者模式关闭时启动开销不因此增加。AiTurnTrace.startTraceRootSpan也做了对应的非录制 span 防御开发者模式关闭时startSpan返回的是NonRecordingSpan没有startTime字段若不加守卫会在每次回合结束时抛错因此转换前先检查startTime in spanAiTurnTrace.ts。十、渲染端查看器TracePage 的按需轮询模型渲染端查看器 TracePage.tsx 接收topicId与traceId通过trace:getDataIPC 轮询主进程进行中的 trace 以 1 秒间隔、空闲时降为 5 秒TRACE_POLL_INTERVAL_MS/TRACE_IDLE_POLL_INTERVAL_MS。它使用TraceTreeModel在本地维护节点树根据响应的reset标志决定整体重置或增量应用 span 变更TracePage.tsx并支持选中 span 查看详情SpanDetail与展开/折叠树节点。整个查看器只消费主进程持久化的数据从不自己采集 span因此在开发者模式关闭时它只能呈现空链路。结语一条可追溯、可扩展、受控的本地链路管道从整体看Cherry Studio 的可观测性子系统把AI 调用可追溯落地为一条清晰的主进程管道span 产生AI SDK 自动 span Cherry 自有根 span Pi/DSH 自有 span Claude Code OTLP→统一转换SpanEntity含用量归一化与属性层级恢复→sink 分发本地投影为默认 sink接口预留外部导出→内存投影与流终止落盘JSONL 历史 多级内存/文件上限→渲染端按需增量读取。它用开发者模式门控和明文但本地、且权限收紧的取舍平衡了调试价值与敏感面并且所有环节都有对应的源码与测试用例可以继续深挖子系统入口与导出src/main/ai/observability/index.ts存储与保留策略src/main/ai/observability/storage/TraceStorageService.tsAI SDK span 转换src/main/ai/observability/adapters/aiSdk/aiSdkSpanAdapter.tsClaude Code OTLP 接收端src/main/ai/observability/adapters/claudeCode/ClaudeCodeTraceBridgeService.ts渲染端查看器src/renderer/components/chat/trace/TracePage.tsx原始规范文档docs/references/ai/observability.md【免费下载链接】cherry-studio Cherry Studio 是一款支持多个 LLM 提供商的桌面客户端项目地址: https://gitcode.com/CherryHQ/cherry-studio创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考