Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com> Signed-off-by: suyao <sy20010504@gmail.com>
5.8 KiB
Observability
The src/main/ai/observability/ subsystem: OTel tracing, the local span
projection, and the sink registry. "Trace / telemetry" is the
user-facing surface; this doc covers the whole subsystem.
What's instrumented
Every AI SDK call run through Cherry produces an OpenTelemetry span tree:
chat.turn (root, created by context provider)
├── ai.streamText (AI SDK auto)
│ ├── ai.streamText.doStream (AI SDK auto)
│ ├── ai.toolCall (per tool invocation) (AI SDK auto)
│ └── ai.streamText.<step> (AI SDK auto)
└── attributes: topicId, modelName, … (set by AiTurnTrace / AdapterTracer)
AI SDK's experimental_telemetry produces the inner spans; Cherry owns
the root span through AiTurnTrace so it lands in the same observability
path without going through the AI SDK adapter.
The main-process observability boundary is src/main/ai/observability:
core/creates Cherry-owned turn roots and commoncs.*attributes.adapters/aiSdk/interprets AI SDK child spans.adapters/claudeCode/interprets Claude Code OTLP spans and logs.storage/keeps the in-memory span projection and JSONL-compatible history.sinks/defines the extension point for local and future external export.
Local history flush
The container traceId is persisted on the topic / agent-session row (one trace
tree per container), but the span tree is first collected in the main-process
TraceStorageService memory store.
The durable history file is written by the stream terminal path:
PersistentChatContextProviderattaches aTraceFlushListenerto normal chat turns.AgentSessionRuntimeServiceattaches the same listener toagent-session:${sessionId}turns, including queued follow-up turns.- On the topic-level terminal event (
done,paused, orerror),TraceFlushListenercallsTraceStorageService.saveSpans(topicId). - Flush errors are logged as warnings and do not affect message completion.
Collection and persistence are main-process only. Spans live in
TraceStorageService's in-memory store and are flushed to the JSONL history file
on the terminal event. The renderer trace viewer (TracePage) reads the persisted
spans on demand through the trace.getData IPC — it never collects spans itself.
AdapterTracer
src/main/ai/observability/adapters/aiSdk/adapterTracer.ts wraps the OTel Tracer returned
by the global provider. On every startSpan / startActiveSpan it:
- Patches
span.end()to also callAiSdkSpanAdapter.convertToSpanEntity(...)and hand the result to the observability sink registry. - Stamps
trace.topicIdandtrace.modelNameso the main-sideTraceStorageServicecan key spans per topic.
AdapterTracer is intentionally only for AI SDK child spans:
buildTelemetry(runtime/aiSdk/params/buildTelemetry.ts) — passed to AI SDK asexperimental_telemetry.tracer. Captures every AI SDK auto-span. Returnsundefined(no telemetry, no tracer) when there is notopicIdor developer mode is off — see below.
AiSdkSpanAdapter
src/main/ai/observability/adapters/aiSdk/aiSdkSpanAdapter.ts converts an OTel span into the
SpanEntity shape TraceStorageService stores and persists:
- Reads span name, attributes, events, status, links.
- Recovers AI SDK's hierarchical attribute conventions:
ai.xxxis a level,ai.xxx.yyyis a sub-level under it. - Normalises usage attributes across the base and LLM spans, preferring AI SDK v6 keys with legacy
and semantic-convention fallbacks: input from
ai.usage.inputTokens/ai.usage.promptTokens/gen_ai.usage.input_tokens(and the singleai.usage.tokensembeddings shape), output fromai.usage.outputTokens/ai.usage.completionTokens/gen_ai.usage.output_tokens, plusai.usage.totalTokens,ai.usage.cachedInputTokens, andai.usage.reasoningTokens(emitted ascompletion_tokens_details.reasoning_tokens).
Claude Code Agent SDK spans do not go through AiSdkSpanAdapter; they are
converted by src/main/ai/observability/adapters/claudeCode/ClaudeCodeOtlpAdapter.ts.
Sensitive data capture & redaction
Cross-referenced from
ClaudeCodeTraceBridgeService.prepareTrace.
The Claude Code OTLP bridge runs only when developer mode is enabled. When it does, it intentionally turns on verbose Claude Code telemetry:
OTEL_LOG_USER_PROMPTS— user prompt textOTEL_LOG_TOOL_DETAILS/OTEL_LOG_TOOL_CONTENT— tool calls and their contentOTEL_LOG_RAW_API_BODIES— raw API request/response bodies
These payloads land in span attributes that TraceStorageService persists as
plaintext JSONL trace files on disk, so a trace can contain secrets
(authorization headers, API keys embedded in raw bodies) alongside the prompt
and tool content.
Redaction is deliberately not done. Stripping secrets would mean parsing arbitrary OTLP attribute structures across the ingest path and would risk dropping legitimate trace data. The accepted tradeoff is that capture is local-only and developer-gated; turning that into a redaction/threat-model guarantee is a deferred decision. Treat exported trace files as sensitive.
Developer-mode gating
Dev mode only. The span projection (TraceStorageService) is built and persisted
in the main process; the renderer trace viewer (TracePage) reads it on demand via
the trace.getData IPC. Outside developer mode buildTelemetry returns undefined,
so no tracer is attached at all and the AI SDK emits no spans — there is nothing
to project, and the viewer shows an empty trace.
Where to read more
- Code:
src/main/ai/observability/ - Span projection:
src/main/ai/observability/storage/TraceStorageService.ts - AI SDK telemetry docs: https://ai-sdk.dev/docs/reference/ai-sdk-core/telemetry