mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* Python: Add OpenTelemetry instrumentation to ClaudeAgent (#4278) Add inline telemetry to ClaudeAgent.run() so that enable_instrumentation() emits invoke_agent spans and metrics. Covers both streaming and non-streaming paths using the same observability helpers as AgentTelemetryLayer. Adds 5 unit tests for telemetry behavior. Co-Authored-By: amitmukh <amimukherjee@microsoft.com> * Address PR review feedback for ClaudeAgent telemetry - Add justification comment for private observability API imports - Pass system_instructions to capture_messages for system prompt capture - Use monkeypatch instead of try/finally for test global state isolation Co-Authored-By: amitmukh <amitmukh@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> * Adopt AgentTelemetryLayer instead of inline telemetry Restructure ClaudeAgent to inherit from AgentTelemetryLayer via a _ClaudeAgentRunImpl mixin, eliminating duplicated telemetry code and private API imports. MRO: ClaudeAgent → AgentTelemetryLayer → _ClaudeAgentRunImpl → BaseAgent - Remove inline _run_with_telemetry / _run_with_telemetry_stream methods - Remove private observability helper imports (_capture_messages, etc.) - Add default_options property mapping system_prompt → instructions - Net -105 lines by reusing core telemetry layer Co-Authored-By: amitmukh <amitmukh@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> * Fix mypy: align _ClaudeAgentRunImpl.run() signature with AgentTelemetryLayer.run() Remove explicit `options` parameter from mixin's run() signature and extract it from **kwargs to match AgentTelemetryLayer's signature. Also align overload return types (ResponseStream, Awaitable) to match. Co-Authored-By: Claude <noreply@anthropic.com> * Introduce RawClaudeAgent following framework's RawAgent/Agent pattern Replace private _ClaudeAgentRunImpl mixin with public RawClaudeAgent class that contains all core logic (init, run, lifecycle, tools). ClaudeAgent becomes a thin wrapper that adds AgentTelemetryLayer. - RawClaudeAgent(BaseAgent): full implementation without telemetry - ClaudeAgent(AgentTelemetryLayer, RawClaudeAgent): adds OTel tracing - Export RawClaudeAgent from package __init__.py Users who want to skip telemetry or provide their own can use RawClaudeAgent directly. Co-Authored-By: Claude <noreply@anthropic.com> * Address review nits: trim RawClaudeAgent docstring, fix import paths - Simplify RawClaudeAgent docstring to a single basic example (not the primary entry point for most users) - Use agent_framework.anthropic import path in docstrings instead of direct agent_framework_claude path - Add RawClaudeAgent to agent_framework.anthropic lazy re-exports Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Amit Mukherjee <amimukherjee@microsoft.com> Co-authored-by: amitmukh <amitmukh@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude
Amit Mukherjee
amitmukh
Dmytro Struk
parent
c5ed8209df
commit
fae36b36f2
@@ -945,3 +945,191 @@ class TestClaudeAgentStructuredOutput:
|
||||
with pytest.raises(AgentException) as exc_info:
|
||||
await agent.run("Hello")
|
||||
assert "Something went wrong" in str(exc_info.value)
|
||||
|
||||
|
||||
# region Test ClaudeAgent Telemetry
|
||||
|
||||
|
||||
class TestClaudeAgentTelemetry:
|
||||
"""Tests for ClaudeAgent OpenTelemetry instrumentation."""
|
||||
|
||||
@staticmethod
|
||||
async def _create_async_generator(items: list[Any]) -> Any:
|
||||
"""Helper to create async generator from list."""
|
||||
for item in items:
|
||||
yield item
|
||||
|
||||
def _create_mock_client(self, messages: list[Any]) -> MagicMock:
|
||||
"""Create a mock ClaudeSDKClient that yields given messages."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.connect = AsyncMock()
|
||||
mock_client.disconnect = AsyncMock()
|
||||
mock_client.query = AsyncMock()
|
||||
mock_client.set_model = AsyncMock()
|
||||
mock_client.set_permission_mode = AsyncMock()
|
||||
mock_client.receive_response = MagicMock(return_value=self._create_async_generator(messages))
|
||||
return mock_client
|
||||
|
||||
def _create_standard_messages(self) -> list[Any]:
|
||||
"""Create a standard set of mock messages for testing."""
|
||||
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock
|
||||
from claude_agent_sdk.types import StreamEvent
|
||||
|
||||
return [
|
||||
StreamEvent(
|
||||
event={
|
||||
"type": "content_block_delta",
|
||||
"delta": {"type": "text_delta", "text": "Hello!"},
|
||||
},
|
||||
uuid="event-1",
|
||||
session_id="session-123",
|
||||
),
|
||||
AssistantMessage(
|
||||
content=[TextBlock(text="Hello!")],
|
||||
model="claude-sonnet",
|
||||
),
|
||||
ResultMessage(
|
||||
subtype="success",
|
||||
duration_ms=100,
|
||||
duration_api_ms=50,
|
||||
is_error=False,
|
||||
num_turns=1,
|
||||
session_id="session-123",
|
||||
),
|
||||
]
|
||||
|
||||
async def test_run_emits_span_when_instrumentation_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that run() creates an OpenTelemetry span when instrumentation is enabled."""
|
||||
from agent_framework.observability import OBSERVABILITY_SETTINGS
|
||||
|
||||
messages = self._create_standard_messages()
|
||||
mock_client = self._create_mock_client(messages)
|
||||
|
||||
monkeypatch.setattr(OBSERVABILITY_SETTINGS, "enable_instrumentation", True)
|
||||
|
||||
with (
|
||||
patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client),
|
||||
patch("agent_framework.observability._get_span") as mock_get_span,
|
||||
):
|
||||
mock_span = MagicMock()
|
||||
mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span)
|
||||
mock_get_span.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
agent = ClaudeAgent(name="test-agent")
|
||||
response = await agent.run("Hello")
|
||||
|
||||
assert response.text == "Hello!"
|
||||
mock_get_span.assert_called_once()
|
||||
call_kwargs = mock_get_span.call_args[1]
|
||||
assert call_kwargs["attributes"]["gen_ai.agent.name"] == "test-agent"
|
||||
assert call_kwargs["attributes"]["gen_ai.operation.name"] == "invoke_agent"
|
||||
|
||||
async def test_run_skips_telemetry_when_instrumentation_disabled(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that run() skips telemetry when instrumentation is disabled."""
|
||||
from agent_framework.observability import OBSERVABILITY_SETTINGS
|
||||
|
||||
messages = self._create_standard_messages()
|
||||
mock_client = self._create_mock_client(messages)
|
||||
|
||||
monkeypatch.setattr(OBSERVABILITY_SETTINGS, "enable_instrumentation", False)
|
||||
|
||||
with (
|
||||
patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client),
|
||||
patch("agent_framework.observability._get_span") as mock_get_span,
|
||||
):
|
||||
agent = ClaudeAgent(name="test-agent")
|
||||
response = await agent.run("Hello")
|
||||
|
||||
assert response.text == "Hello!"
|
||||
mock_get_span.assert_not_called()
|
||||
|
||||
async def test_run_stream_emits_span_when_instrumentation_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that run(stream=True) creates a span when instrumentation is enabled."""
|
||||
from agent_framework.observability import OBSERVABILITY_SETTINGS
|
||||
|
||||
messages = self._create_standard_messages()
|
||||
mock_client = self._create_mock_client(messages)
|
||||
|
||||
monkeypatch.setattr(OBSERVABILITY_SETTINGS, "enable_instrumentation", True)
|
||||
|
||||
with (
|
||||
patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client),
|
||||
patch("agent_framework.observability.get_tracer") as mock_get_tracer,
|
||||
):
|
||||
mock_span = MagicMock()
|
||||
mock_tracer = MagicMock()
|
||||
mock_tracer.start_span.return_value = mock_span
|
||||
mock_get_tracer.return_value = mock_tracer
|
||||
|
||||
agent = ClaudeAgent(name="stream-agent")
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("Hello", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 1
|
||||
mock_tracer.start_span.assert_called_once()
|
||||
span_name = mock_tracer.start_span.call_args[0][0]
|
||||
assert "stream-agent" in span_name
|
||||
assert "invoke_agent" in span_name
|
||||
|
||||
async def test_run_captures_exception_in_span(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that exceptions during run() are captured in the telemetry span."""
|
||||
from agent_framework.exceptions import AgentException
|
||||
from agent_framework.observability import OBSERVABILITY_SETTINGS
|
||||
from claude_agent_sdk import ResultMessage
|
||||
|
||||
error_messages = [
|
||||
ResultMessage(
|
||||
subtype="error",
|
||||
duration_ms=100,
|
||||
duration_api_ms=50,
|
||||
is_error=True,
|
||||
num_turns=0,
|
||||
session_id="error-session",
|
||||
result="Model not found",
|
||||
),
|
||||
]
|
||||
mock_client = self._create_mock_client(error_messages)
|
||||
|
||||
monkeypatch.setattr(OBSERVABILITY_SETTINGS, "enable_instrumentation", True)
|
||||
|
||||
with (
|
||||
patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client),
|
||||
patch("agent_framework.observability._get_span") as mock_get_span,
|
||||
patch("agent_framework.observability.capture_exception") as mock_capture_exc,
|
||||
):
|
||||
mock_span = MagicMock()
|
||||
mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span)
|
||||
mock_get_span.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
agent = ClaudeAgent(name="error-agent")
|
||||
with pytest.raises(AgentException):
|
||||
await agent.run("Hello")
|
||||
|
||||
mock_capture_exc.assert_called_once()
|
||||
exc_kwargs = mock_capture_exc.call_args[1]
|
||||
assert exc_kwargs["span"] is mock_span
|
||||
assert isinstance(exc_kwargs["exception"], AgentException)
|
||||
|
||||
async def test_telemetry_uses_correct_provider_name(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Test that telemetry uses AGENT_PROVIDER_NAME as provider."""
|
||||
from agent_framework.observability import OBSERVABILITY_SETTINGS
|
||||
|
||||
messages = self._create_standard_messages()
|
||||
mock_client = self._create_mock_client(messages)
|
||||
|
||||
monkeypatch.setattr(OBSERVABILITY_SETTINGS, "enable_instrumentation", True)
|
||||
|
||||
with (
|
||||
patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client),
|
||||
patch("agent_framework.observability._get_span") as mock_get_span,
|
||||
):
|
||||
mock_span = MagicMock()
|
||||
mock_get_span.return_value.__enter__ = MagicMock(return_value=mock_span)
|
||||
mock_get_span.return_value.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
agent = ClaudeAgent(name="test-agent")
|
||||
await agent.run("Hello")
|
||||
|
||||
call_kwargs = mock_get_span.call_args[1]
|
||||
assert call_kwargs["attributes"]["gen_ai.provider.name"] == "anthropic.claude"
|
||||
|
||||
Reference in New Issue
Block a user