Plan: OTel Tracing for Claude Backend via SDK Hooks¶
Context¶
The Claude Agent SDK spawns a subprocess that generates its own OTel traces with independent trace IDs. These traces appear as separate root spans in the OTel dashboard — not as children of HoloDeck's holodeck.cli.test span. The fix: use the SDK's hooks API (PreToolUse, PostToolUse, PostToolUseFailure) to create OTel spans from HoloDeck's own TracerProvider, so all Claude operations nest under the existing application spans.
Key finding (verified via scripts/test_hooks_query.py): Hooks only work with
ClaudeSDKClient (streaming input mode), NOT with query() (single message mode).
Although InternalClient.process_query() wires options.hooks through at _internal/client.py:107,
the hook callbacks fail with Stream closed — the bidirectional sendRequest channel that hooks
depend on is torn down before callbacks can execute. The official docs confirm this limitation:
single message mode does not support hook integration.
Consequence: invoke_once() must switch from query() to ClaudeSDKClient to support
OTel hook-based tracing. Each stateless test invocation wraps a short-lived ClaudeSDKClient
session (connect → send → receive → disconnect). This is a net-new change to claude_backend.py.
Span Hierarchy (when observability enabled)¶
holodeck.cli.test (existing — test.py)
└── holodeck.claude.invoke (NEW — wraps ClaudeSDKClient session / session.send)
├── holodeck.claude.tool (NEW — from PreToolUse/PostToolUse hooks)
├── holodeck.claude.tool (NEW — per tool call)
└── holodeck.claude.tool (NEW — per tool call)
Files to Create/Modify¶
| File | Action |
|---|---|
src/holodeck/lib/backends/claude_otel_hooks.py |
CREATE — Hook callbacks + span tracking |
src/holodeck/lib/backends/claude_backend.py |
MODIFY — Wire hooks into build_options(), add wrapper spans |
tests/unit/lib/backends/test_claude_otel_hooks.py |
CREATE — Unit tests for hooks module |
tests/unit/lib/backends/test_claude_backend.py |
MODIFY — Tests for hooks integration |
Implementation Steps¶
Step 1: Create src/holodeck/lib/backends/claude_otel_hooks.py¶
New module with ClaudeOtelHookFactory class:
_ActiveToolSpandataclass: holdsspan,context_token,start_timeClaudeOtelHookFactory.__init__(observability_config): stores config, creates empty_active_spans: dict[str, _ActiveToolSpan]build_hooks() -> dict[str, list[HookMatcher]]: returns hook config forPreToolUse,PostToolUse,PostToolUseFailure_on_pre_tool_use(): creates spanholodeck.claude.toolwithtool.name,tool.use_idattributes; stores in_active_spans[tool_use_id]; optionally capturestool.inputifcapture_content=True_on_post_tool_use(): ends span withStatusCode.OK, setstool.duration_s,tool.status=success; optionally capturestool.response_on_post_tool_use_failure(): ends span withStatusCode.ERROR, records error messagecleanup_orphaned_spans(): ends any uncompleted spans during teardown
Error handling: Every hook callback wrapped in try/except Exception — logs at DEBUG, always returns {"continue_": True}. Hooks must never block the SDK.
Privacy: tool.input and tool.response only captured when observability_config.traces.capture_content is True (matches existing pattern in evaluators).
Step 2: Update build_options() in claude_backend.py¶
Add optional hooks: dict[HookEvent, list[HookMatcher]] | None = None parameter
(using SDK-native types from claude_agent_sdk.types). Pass through to
ClaudeAgentOptions(**opts_kwargs) when not None.
Step 3: Update ClaudeBackend.initialize() in claude_backend.py¶
After step 7 (OTel env vars), before step 8 (build options):
- Check _is_observability_enabled() (new helper: checks agent.observability.enabled and agent.observability.traces.enabled)
- If enabled: create ClaudeOtelHookFactory, call build_hooks(), pass to build_options()
- Store factory as self._otel_hook_factory for cleanup
Step 4: Switch invoke_once() from query() to ClaudeSDKClient¶
Why: query() does not support hooks (see Key Finding above). invoke_once() must use
ClaudeSDKClient to get OTel hook callbacks.
Change in claude_backend.py: Replace the current _invoke_query() implementation that
iterates over query(prompt=message, options=self._options) with a short-lived
ClaudeSDKClient session:
async def _invoke_with_client(self, message: str) -> ExecutionResult:
async with ClaudeSDKClient(self._options) as client:
await client.query(message)
async for msg in client.receive_response():
# Same message processing as current _invoke_query()
...
Each test invocation opens and closes its own session. This is equivalent to the current
query() behavior (fresh session per call) but enables the hooks bidirectional channel.
Wrapper span: Wrap entire method body in holodeck.claude.invoke span using existing
nullcontext() pattern:
- Attributes: holodeck.input_length, holodeck.num_turns, holodeck.token_usage.prompt, holodeck.token_usage.completion, holodeck.tool_calls_count
Step 5: Add wrapper span in ClaudeSession.send()¶
Same pattern. Pass observability_enabled: bool through ClaudeSession.__init__() (set by ClaudeBackend.create_session()).
Step 6: Update ClaudeBackend.teardown()¶
Call self._otel_hook_factory.cleanup_orphaned_spans() if factory exists.
Step 7: Write tests¶
test_claude_otel_hooks.py (9 tests):
- build_hooks() returns correct structure (3 hook events)
- _on_pre_tool_use creates span with correct attributes
- _on_post_tool_use ends span with OK status and duration
- _on_post_tool_use_failure ends span with ERROR status
- Exception in hook is swallowed (returns {"continue_": True})
- Unknown tool_use_id in PostToolUse is a no-op
- cleanup_orphaned_spans ends all active spans
- Content capture respects capture_content toggle
- tool_use_id=None handled gracefully
test_claude_backend.py updates (4-5 tests):
- build_options() with hooks passes them through
- initialize() with observability creates hook factory
- initialize() without observability skips hooks
- teardown() calls cleanup
Step 8: Code quality¶
make format && make lint-fix && make type-check && pytest tests/unit/lib/backends/ -n auto -v
Span Attributes¶
| Attribute | Span | Type | Description |
|---|---|---|---|
holodeck.input_length |
invoke |
int | User input char count |
holodeck.num_turns |
invoke |
int | SDK agentic turns |
holodeck.token_usage.prompt |
invoke |
int | Prompt tokens |
holodeck.token_usage.completion |
invoke |
int | Completion tokens |
holodeck.tool_calls_count |
invoke |
int | Number of tool calls |
tool.name |
tool |
str | Tool name from SDK |
tool.use_id |
tool |
str | Tool use ID |
tool.status |
tool |
str | "success" or "error" |
tool.duration_s |
tool |
float | Wall-clock seconds |
tool.input |
tool |
str | Tool input (opt-in) |
tool.response |
tool |
str | Tool response (opt-in) |
tool.error |
tool |
str | Error message (failures) |
What Changes (beyond OTel)¶
invoke_once()switches fromquery()toClaudeSDKClient— same observable behavior (fresh session per call), but enables hooks. This is required regardless of OTel; it also fixes a latent issue wherequery()cannot support future hook-dependent features.
What Does NOT Change¶
otel_bridge.py(subprocess env vars) — complementary, not replacedClaudeSessioncore flow (chat path): no hook-specific control-flow changes required. It will use hooks oncebuild_options()injects them into sharedClaudeAgentOptions.ObservabilityConfigmodel- CLI commands (test.py, chat.py)
- SK backend path (agent_factory.py)
- No new external dependencies