Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement Claude Agent SDK instrumentation: `invoke_agent` spans for `query()` and `ClaudeSDKClient` response turns, with nested `execute_tool` and subagent `invoke_agent` spans, token usage, and optional message-content capture
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,21 @@ OpenTelemetry Claude Agent SDK Instrumentation
.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-genai-claude-agent-sdk.svg
:target: https://pypi.org/project/opentelemetry-instrumentation-genai-claude-agent-sdk/

This library allows tracing LLM requests made by the
`Claude Agent SDK <https://github.com/anthropics/claude-agent-sdk-python>`_.
This library traces agent runs made with the
`Claude Agent SDK <https://github.com/anthropics/claude-agent-sdk-python>`_,
following the OpenTelemetry GenAI semantic conventions:

* ``query()`` and each ``ClaudeSDKClient.receive_response()`` turn become
``invoke_agent`` spans carrying the prompt, assistant output messages,
token usage, model, and session id (``gen_ai.conversation.id``).
* Tool executions become nested ``execute_tool`` spans.
* Subagent runs (e.g. via the Agent/Task tool) become nested
``invoke_agent`` spans under the spawning tool's span, named from the
subagent type.

Spans are derived from the SDK's streamed messages. When the model issues
parallel tool calls in a single turn, sibling tool spans may be parented
under each other rather than side by side; attributes remain correct.

Installation
------------
Expand Down Expand Up @@ -77,6 +90,20 @@ environment variable ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`` to o
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_AND_EVENT


Correlate with Claude Code's native traces
******************************************

The Claude Code CLI that the SDK drives has its own built-in (beta)
OpenTelemetry tracing (``claude_code.*`` spans for model requests, tool
executions, and hooks), exported directly from the CLI process. This
instrumentation neither requires nor conflicts with it: the SDK propagates
W3C trace context into the CLI subprocess, so when native tracing is
enabled the CLI's spans join the same trace, alongside the semantic
convention spans emitted here (for ``query()`` runs, nested under the
``invoke_agent`` span). See the `Agent SDK observability guide
<https://code.claude.com/docs/en/agent-sdk/observability>`_ for the CLI
telemetry configuration.

References
----------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ dependencies = [
]

[project.optional-dependencies]
instruments = ["claude-agent-sdk >= 0.1.14"]
instruments = ["claude-agent-sdk >= 0.1.45"]

[project.entry-points.opentelemetry_instrumentor]
claude-agent-sdk = "opentelemetry.instrumentation.genai.claude_agent_sdk:ClaudeAgentSDKInstrumentor"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@
Instrumentation for the `Claude Agent SDK
<https://github.com/anthropics/claude-agent-sdk-python>`_.

The Claude Agent SDK runs an agent loop through the bundled Claude Code
CLI; telemetry is derived from the streamed messages: ``query()`` and each
``ClaudeSDKClient.receive_response()`` turn produce ``invoke_agent`` spans
carrying the prompt, assistant output messages, token usage, model, and
session id; tool executions produce nested ``execute_tool`` spans; and
subagent runs produce nested ``invoke_agent`` spans.

Usage
-----

.. code-block:: python

from opentelemetry.instrumentation.genai.claude_agent_sdk import ClaudeAgentSDKInstrumentor
from claude_agent_sdk import ClaudeAgentOptions, AgentDefinition, AssistantMessage, TextBlock, query
from claude_agent_sdk import AssistantMessage, TextBlock, query

# Enable instrumentation
ClaudeAgentSDKInstrumentor().instrument()
Expand All @@ -23,18 +30,7 @@
import anyio

async def main():
options = ClaudeAgentOptions(
agents={
"assistant": AgentDefinition(
description="A helpful assistant",
prompt="You are a helpful assistant.",
tools=["Read"],
model="sonnet",
),
},
)

async for message in query(prompt="Hello!", options=options):
async for message in query(prompt="Hello!"):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
Expand All @@ -52,31 +48,36 @@ async def main():
---
"""

from __future__ import annotations

import importlib
from typing import Any, Collection

from opentelemetry._logs import get_logger
from wrapt import wrap_function_wrapper

from opentelemetry.instrumentation.genai.claude_agent_sdk.package import (
_instruments,
)
from opentelemetry.instrumentation.genai.claude_agent_sdk.patch import (
client_connect_wrapper,
client_query_wrapper,
client_receive_response_wrapper,
query_wrapper,
)
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
from opentelemetry.metrics import get_meter
from opentelemetry.semconv.schemas import Schemas
from opentelemetry.trace import get_tracer
from opentelemetry.instrumentation.utils import unwrap
from opentelemetry.util.genai.handler import TelemetryHandler


class ClaudeAgentSDKInstrumentor(BaseInstrumentor):
"""An instrumentor for the Claude Agent SDK.

This instrumentor will automatically trace Anthropic API calls and
optionally capture message content as events.
This instrumentor traces agent runs (``query()`` and
``ClaudeSDKClient`` response turns) as ``invoke_agent`` spans with
nested ``execute_tool`` and subagent ``invoke_agent`` spans, and
optionally captures message content.
"""

def __init__(self) -> None:
super().__init__()
self._tracer = None
self._logger = None
self._meter = None

# pylint: disable=no-self-use
def instrumentation_dependencies(self) -> Collection[str]:
return _instruments
Expand All @@ -89,47 +90,58 @@ def _instrument(self, **kwargs: Any) -> None:
- tracer_provider: TracerProvider instance
- meter_provider: MeterProvider instance
- logger_provider: LoggerProvider instance
- completion_hook: CompletionHook instance
"""

# Get providers from kwargs
tracer_provider = kwargs.get("tracer_provider")
logger_provider = kwargs.get("logger_provider")
meter_provider = kwargs.get("meter_provider")

# Initialize tracer
tracer = get_tracer(
__name__,
"",
tracer_provider,
schema_url=Schemas.V1_28_0.value,
handler = TelemetryHandler(
tracer_provider=kwargs.get("tracer_provider"),
meter_provider=kwargs.get("meter_provider"),
logger_provider=kwargs.get("logger_provider"),
completion_hook=kwargs.get("completion_hook"),
)

# Initialize logger for events
logger = get_logger(
__name__,
"",
schema_url=Schemas.V1_28_0.value,
logger_provider=logger_provider,
wrap_function_wrapper(
"claude_agent_sdk.query",
"query",
query_wrapper(handler),
)

# Initialize meter for metrics
meter = get_meter(
__name__,
"",
meter_provider,
schema_url=Schemas.V1_28_0.value,
wrap_function_wrapper(
"claude_agent_sdk.client",
"ClaudeSDKClient.connect",
client_connect_wrapper(handler),
)

# Store for later use in _uninstrument
self._tracer = tracer
self._logger = logger
self._meter = meter

# Patching will be added in a follow-up PR
wrap_function_wrapper(
"claude_agent_sdk.client",
"ClaudeSDKClient.query",
client_query_wrapper(handler),
)
wrap_function_wrapper(
"claude_agent_sdk.client",
"ClaudeSDKClient.receive_response",
client_receive_response_wrapper(handler),
)
self._sync_package_query_export()

def _uninstrument(self, **kwargs: Any) -> None:
"""Disable Claude Agent SDK instrumentation.

This removes all patches applied during instrumentation.
"""Disable Claude Agent SDK instrumentation."""
query_module = importlib.import_module("claude_agent_sdk.query")
unwrap(query_module, "query")
client_module = importlib.import_module("claude_agent_sdk.client")
client_class = client_module.ClaudeSDKClient
unwrap(client_class, "connect")
unwrap(client_class, "query")
unwrap(client_class, "receive_response")
self._sync_package_query_export()

@staticmethod
def _sync_package_query_export() -> None:
"""Point the package-level ``query`` re-export at the module attribute.

The ``claude_agent_sdk`` package re-exports ``query`` from its
``claude_agent_sdk.query`` submodule at import time, so patching the
submodule attribute alone would leave
``from claude_agent_sdk import query`` resolving to the unpatched
function (and vice versa on uninstrument).
"""
# Unpatching will be added in a follow-up PR
package = importlib.import_module("claude_agent_sdk")
query_module = importlib.import_module("claude_agent_sdk.query")
setattr(package, "query", query_module.query)
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright The OpenTelemetry Authors
# SPDX-License-Identifier: Apache-2.0

_instruments = ("claude-agent-sdk >= 0.1.14",)
_instruments = ("claude-agent-sdk >= 0.1.45",)
Loading
Loading