mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: [BREAKING] Observability updates (#2782)
* fixes Python: Add env_file_path parameter to setup_observability() similar to AzureOpenAIChatClient Fixes #2186 * WIP on updates using configure_azure_monitor * improved setup and clarity * fixed root .env.example * revert changes * updated files * updated sample * updated zero code * test fixes and fixed links * fix devui * removed planning docs * added enable method and updated readme and samples * clarified docstring * add return annotation * updated naming * update capatilized version * updated readme and some fixes * updated decorator name inline with the rest * feedback from comments addressed
This commit is contained in:
committed by
GitHub
Unverified
parent
3c379718e9
commit
3139347526
@@ -34,7 +34,7 @@ from ._types import (
|
||||
ToolMode,
|
||||
)
|
||||
from .exceptions import AgentExecutionException, AgentInitializationError
|
||||
from .observability import use_agent_observability
|
||||
from .observability import use_agent_instrumentation
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
@@ -516,8 +516,8 @@ class BaseAgent(SerializationMixin):
|
||||
|
||||
|
||||
@use_agent_middleware
|
||||
@use_agent_observability
|
||||
class ChatAgent(BaseAgent):
|
||||
@use_agent_instrumentation(capture_usage=False) # type: ignore[arg-type,misc]
|
||||
class ChatAgent(BaseAgent): # type: ignore[misc]
|
||||
"""A Chat Client Agent.
|
||||
|
||||
This is the primary agent implementation that uses a chat client to interact
|
||||
@@ -583,7 +583,7 @@ class ChatAgent(BaseAgent):
|
||||
print(update.text, end="")
|
||||
"""
|
||||
|
||||
AGENT_SYSTEM_NAME: ClassVar[str] = "microsoft.agent_framework"
|
||||
AGENT_PROVIDER_NAME: ClassVar[str] = "microsoft.agent_framework"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, Protocol, TypeVar, run
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._logging import get_logger
|
||||
from ._mcp import MCPTool
|
||||
from ._memory import AggregateContextProvider, ContextProvider
|
||||
from ._middleware import (
|
||||
ChatMiddleware,
|
||||
@@ -426,6 +425,8 @@ class BaseChatClient(SerializationMixin, ABC):
|
||||
else [tools]
|
||||
)
|
||||
for tool in tools_list: # type: ignore[reportUnknownType]
|
||||
from ._mcp import MCPTool
|
||||
|
||||
if isinstance(tool, MCPTool):
|
||||
if not tool.is_connected:
|
||||
await tool.connect()
|
||||
|
||||
@@ -6,11 +6,13 @@ from abc import ABC, abstractmethod
|
||||
from collections.abc import MutableSequence, Sequence
|
||||
from contextlib import AsyncExitStack
|
||||
from types import TracebackType
|
||||
from typing import Any, Final, cast
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
from ._tools import ToolProtocol
|
||||
from ._types import ChatMessage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._tools import ToolProtocol
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override # type: ignore # pragma: no cover
|
||||
else:
|
||||
@@ -54,7 +56,7 @@ class Context:
|
||||
self,
|
||||
instructions: str | None = None,
|
||||
messages: Sequence[ChatMessage] | None = None,
|
||||
tools: Sequence[ToolProtocol] | None = None,
|
||||
tools: Sequence["ToolProtocol"] | None = None,
|
||||
):
|
||||
"""Create a new Context object.
|
||||
|
||||
@@ -65,7 +67,7 @@ class Context:
|
||||
"""
|
||||
self.instructions = instructions
|
||||
self.messages: Sequence[ChatMessage] = messages or []
|
||||
self.tools: Sequence[ToolProtocol] = tools or []
|
||||
self.tools: Sequence["ToolProtocol"] = tools or []
|
||||
|
||||
|
||||
# region ContextProvider
|
||||
@@ -247,7 +249,7 @@ class AggregateContextProvider(ContextProvider):
|
||||
contexts = await asyncio.gather(*[provider.invoking(messages, **kwargs) for provider in self.providers])
|
||||
instructions: str = ""
|
||||
return_messages: list[ChatMessage] = []
|
||||
tools: list[ToolProtocol] = []
|
||||
tools: list["ToolProtocol"] = []
|
||||
for ctx in contexts:
|
||||
if ctx.instructions:
|
||||
instructions += ctx.instructions
|
||||
|
||||
@@ -339,11 +339,17 @@ class SerializationMixin:
|
||||
continue
|
||||
# Handle dicts containing SerializationProtocol values
|
||||
if isinstance(value, dict):
|
||||
from datetime import date, datetime, time
|
||||
|
||||
serialized_dict: dict[str, Any] = {}
|
||||
for k, v in value.items():
|
||||
if isinstance(v, SerializationProtocol):
|
||||
serialized_dict[k] = v.to_dict(exclude=exclude, exclude_none=exclude_none)
|
||||
continue
|
||||
# Convert datetime objects to strings
|
||||
if isinstance(v, (datetime, date, time)):
|
||||
serialized_dict[k] = str(v)
|
||||
continue
|
||||
# Check if the value is JSON serializable
|
||||
if is_serializable(v):
|
||||
serialized_dict[k] = v
|
||||
|
||||
@@ -1816,13 +1816,14 @@ def prepare_function_call_results(content: Contents | Any | list[Contents | Any]
|
||||
"""Prepare the values of the function call results."""
|
||||
if isinstance(content, Contents):
|
||||
# For BaseContent objects, use to_dict and serialize to JSON
|
||||
return json.dumps(content.to_dict(exclude={"raw_representation", "additional_properties"}))
|
||||
# Use default=str to handle datetime and other non-JSON-serializable objects
|
||||
return json.dumps(content.to_dict(exclude={"raw_representation", "additional_properties"}), default=str)
|
||||
|
||||
dumpable = _prepare_function_call_results_as_dumpable(content)
|
||||
if isinstance(dumpable, str):
|
||||
return dumpable
|
||||
# fallback
|
||||
return json.dumps(dumpable)
|
||||
# fallback - use default=str to handle datetime and other non-JSON-serializable objects
|
||||
return json.dumps(dumpable, default=str)
|
||||
|
||||
|
||||
# region Chat Response constants
|
||||
|
||||
@@ -21,7 +21,7 @@ from agent_framework import (
|
||||
use_function_invocation,
|
||||
)
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.observability import use_observability
|
||||
from agent_framework.observability import use_instrumentation
|
||||
from agent_framework.openai._chat_client import OpenAIBaseChatClient
|
||||
|
||||
from ._shared import (
|
||||
@@ -41,7 +41,7 @@ TAzureOpenAIChatClient = TypeVar("TAzureOpenAIChatClient", bound="AzureOpenAICha
|
||||
|
||||
|
||||
@use_function_invocation
|
||||
@use_observability
|
||||
@use_instrumentation
|
||||
@use_chat_middleware
|
||||
class AzureOpenAIChatClient(AzureOpenAIConfigMixin, OpenAIBaseChatClient):
|
||||
"""Azure OpenAI Chat completion class."""
|
||||
|
||||
@@ -10,7 +10,7 @@ from pydantic import ValidationError
|
||||
|
||||
from agent_framework import use_chat_middleware, use_function_invocation
|
||||
from agent_framework.exceptions import ServiceInitializationError
|
||||
from agent_framework.observability import use_observability
|
||||
from agent_framework.observability import use_instrumentation
|
||||
from agent_framework.openai._responses_client import OpenAIBaseResponsesClient
|
||||
|
||||
from ._shared import (
|
||||
@@ -22,7 +22,7 @@ TAzureOpenAIResponsesClient = TypeVar("TAzureOpenAIResponsesClient", bound="Azur
|
||||
|
||||
|
||||
@use_function_invocation
|
||||
@use_observability
|
||||
@use_instrumentation
|
||||
@use_chat_middleware
|
||||
class AzureOpenAIResponsesClient(AzureOpenAIConfigMixin, OpenAIBaseResponsesClient):
|
||||
"""Azure Responses completion class."""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,7 @@ from .._types import (
|
||||
prepare_function_call_results,
|
||||
)
|
||||
from ..exceptions import ServiceInitializationError
|
||||
from ..observability import use_observability
|
||||
from ..observability import use_instrumentation
|
||||
from ._shared import OpenAIConfigMixin, OpenAISettings
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
@@ -53,7 +53,7 @@ __all__ = ["OpenAIAssistantsClient"]
|
||||
|
||||
|
||||
@use_function_invocation
|
||||
@use_observability
|
||||
@use_instrumentation
|
||||
@use_chat_middleware
|
||||
class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
|
||||
"""OpenAI Assistants client."""
|
||||
|
||||
@@ -44,7 +44,7 @@ from ..exceptions import (
|
||||
ServiceInvalidRequestError,
|
||||
ServiceResponseException,
|
||||
)
|
||||
from ..observability import use_observability
|
||||
from ..observability import use_instrumentation
|
||||
from ._exceptions import OpenAIContentFilterException
|
||||
from ._shared import OpenAIBase, OpenAIConfigMixin, OpenAISettings
|
||||
|
||||
@@ -467,7 +467,7 @@ TOpenAIChatClient = TypeVar("TOpenAIChatClient", bound="OpenAIChatClient")
|
||||
|
||||
|
||||
@use_function_invocation
|
||||
@use_observability
|
||||
@use_instrumentation
|
||||
@use_chat_middleware
|
||||
class OpenAIChatClient(OpenAIConfigMixin, OpenAIBaseChatClient):
|
||||
"""OpenAI Chat completion class."""
|
||||
|
||||
@@ -64,7 +64,7 @@ from ..exceptions import (
|
||||
ServiceInvalidRequestError,
|
||||
ServiceResponseException,
|
||||
)
|
||||
from ..observability import use_observability
|
||||
from ..observability import use_instrumentation
|
||||
from ._exceptions import OpenAIContentFilterException
|
||||
from ._shared import OpenAIBase, OpenAIConfigMixin, OpenAISettings
|
||||
|
||||
@@ -1127,7 +1127,7 @@ TOpenAIResponsesClient = TypeVar("TOpenAIResponsesClient", bound="OpenAIResponse
|
||||
|
||||
|
||||
@use_function_invocation
|
||||
@use_observability
|
||||
@use_instrumentation
|
||||
@use_chat_middleware
|
||||
class OpenAIResponsesClient(OpenAIConfigMixin, OpenAIBaseResponsesClient):
|
||||
"""OpenAI Responses client class."""
|
||||
|
||||
@@ -30,7 +30,6 @@ dependencies = [
|
||||
# telemetry
|
||||
"opentelemetry-api>=1.39.0",
|
||||
"opentelemetry-sdk>=1.39.0",
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.39.0",
|
||||
"opentelemetry-semantic-conventions-ai>=0.4.13",
|
||||
# connectors and functions
|
||||
"openai>=1.99.0",
|
||||
|
||||
@@ -10,7 +10,7 @@ from pytest import fixture
|
||||
|
||||
|
||||
@fixture
|
||||
def enable_otel(request: Any) -> bool:
|
||||
def enable_instrumentation(request: Any) -> bool:
|
||||
"""Fixture that returns a boolean indicating if Otel is enabled."""
|
||||
return request.param if hasattr(request, "param") else True
|
||||
|
||||
@@ -22,20 +22,31 @@ def enable_sensitive_data(request: Any) -> bool:
|
||||
|
||||
|
||||
@fixture
|
||||
def span_exporter(monkeypatch, enable_otel: bool, enable_sensitive_data: bool) -> Generator[SpanExporter]:
|
||||
def span_exporter(monkeypatch, enable_instrumentation: bool, enable_sensitive_data: bool) -> Generator[SpanExporter]:
|
||||
"""Fixture to remove environment variables for ObservabilitySettings."""
|
||||
|
||||
env_vars = [
|
||||
"ENABLE_OTEL",
|
||||
"ENABLE_INSTRUMENTATION",
|
||||
"ENABLE_SENSITIVE_DATA",
|
||||
"OTLP_ENDPOINT",
|
||||
"APPLICATIONINSIGHTS_CONNECTION_STRING",
|
||||
"ENABLE_CONSOLE_EXPORTERS",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_HEADERS",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_HEADERS",
|
||||
"OTEL_SERVICE_NAME",
|
||||
"OTEL_SERVICE_VERSION",
|
||||
"OTEL_RESOURCE_ATTRIBUTES",
|
||||
]
|
||||
|
||||
for key in env_vars:
|
||||
monkeypatch.delenv(key, raising=False) # type: ignore
|
||||
monkeypatch.setenv("ENABLE_OTEL", str(enable_otel)) # type: ignore
|
||||
if not enable_otel:
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", str(enable_instrumentation)) # type: ignore
|
||||
if not enable_instrumentation:
|
||||
# we overwrite sensitive data for tests
|
||||
enable_sensitive_data = False
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", str(enable_sensitive_data)) # type: ignore
|
||||
@@ -51,15 +62,22 @@ def span_exporter(monkeypatch, enable_otel: bool, enable_sensitive_data: bool) -
|
||||
|
||||
# recreate observability settings with values from above and no file.
|
||||
observability_settings = observability.ObservabilitySettings(env_file_path="test.env")
|
||||
observability_settings._configure() # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Configure providers manually without calling _configure() to avoid OTLP imports
|
||||
if enable_instrumentation or enable_sensitive_data:
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
tracer_provider = TracerProvider(resource=observability_settings._resource)
|
||||
trace.set_tracer_provider(tracer_provider)
|
||||
|
||||
monkeypatch.setattr(observability, "OBSERVABILITY_SETTINGS", observability_settings, raising=False) # type: ignore
|
||||
|
||||
with (
|
||||
patch("agent_framework.observability.OBSERVABILITY_SETTINGS", observability_settings),
|
||||
patch("agent_framework.observability.setup_observability"),
|
||||
patch("agent_framework.observability.configure_otel_providers"),
|
||||
):
|
||||
exporter = InMemorySpanExporter()
|
||||
if enable_otel or enable_sensitive_data:
|
||||
if enable_instrumentation or enable_sensitive_data:
|
||||
tracer_provider = trace.get_tracer_provider()
|
||||
if not hasattr(tracer_provider, "add_span_processor"):
|
||||
raise RuntimeError("Tracer provider does not support adding span processors.")
|
||||
|
||||
@@ -33,8 +33,8 @@ from agent_framework.observability import (
|
||||
ChatMessageListTimestampFilter,
|
||||
OtelAttr,
|
||||
get_function_span,
|
||||
use_agent_observability,
|
||||
use_observability,
|
||||
use_agent_instrumentation,
|
||||
use_instrumentation,
|
||||
)
|
||||
|
||||
# region Test constants
|
||||
@@ -157,7 +157,7 @@ def test_start_span_with_tool_call_id(span_exporter: InMemorySpanExporter):
|
||||
assert span.attributes[OtelAttr.TOOL_TYPE] == "function"
|
||||
|
||||
|
||||
# region Test use_observability decorator
|
||||
# region Test use_instrumentation decorator
|
||||
|
||||
|
||||
def test_decorator_with_valid_class():
|
||||
@@ -175,7 +175,7 @@ def test_decorator_with_valid_class():
|
||||
return gen()
|
||||
|
||||
# Apply the decorator
|
||||
decorated_class = use_observability(MockChatClient)
|
||||
decorated_class = use_instrumentation(MockChatClient)
|
||||
assert hasattr(decorated_class, OPEN_TELEMETRY_CHAT_CLIENT_MARKER)
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ def test_decorator_with_missing_methods():
|
||||
|
||||
# Apply the decorator - should not raise an error
|
||||
with pytest.raises(ChatClientInitializationError):
|
||||
use_observability(MockChatClient)
|
||||
use_instrumentation(MockChatClient)
|
||||
|
||||
|
||||
def test_decorator_with_partial_methods():
|
||||
@@ -200,7 +200,7 @@ def test_decorator_with_partial_methods():
|
||||
return Mock()
|
||||
|
||||
with pytest.raises(ChatClientInitializationError):
|
||||
use_observability(MockChatClient)
|
||||
use_instrumentation(MockChatClient)
|
||||
|
||||
|
||||
# region Test telemetry decorator with mock client
|
||||
@@ -235,7 +235,7 @@ def mock_chat_client():
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True)
|
||||
async def test_chat_client_observability(mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data):
|
||||
"""Test that when diagnostics are enabled, telemetry is applied."""
|
||||
client = use_observability(mock_chat_client)()
|
||||
client = use_instrumentation(mock_chat_client)()
|
||||
|
||||
messages = [ChatMessage(role=Role.USER, text="Test message")]
|
||||
span_exporter.clear()
|
||||
@@ -258,8 +258,8 @@ async def test_chat_client_observability(mock_chat_client, span_exporter: InMemo
|
||||
async def test_chat_client_streaming_observability(
|
||||
mock_chat_client, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Test streaming telemetry through the use_observability decorator."""
|
||||
client = use_observability(mock_chat_client)()
|
||||
"""Test streaming telemetry through the use_instrumentation decorator."""
|
||||
client = use_instrumentation(mock_chat_client)()
|
||||
messages = [ChatMessage(role=Role.USER, text="Test")]
|
||||
span_exporter.clear()
|
||||
# Collect all yielded updates
|
||||
@@ -282,7 +282,7 @@ async def test_chat_client_streaming_observability(
|
||||
|
||||
async def test_chat_client_without_model_id_observability(mock_chat_client, span_exporter: InMemorySpanExporter):
|
||||
"""Test telemetry shouldn't fail when the model_id is not provided for unknown reason."""
|
||||
client = use_observability(mock_chat_client)()
|
||||
client = use_instrumentation(mock_chat_client)()
|
||||
messages = [ChatMessage(role=Role.USER, text="Test")]
|
||||
span_exporter.clear()
|
||||
response = await client.get_response(messages=messages)
|
||||
@@ -301,7 +301,7 @@ async def test_chat_client_streaming_without_model_id_observability(
|
||||
mock_chat_client, span_exporter: InMemorySpanExporter
|
||||
):
|
||||
"""Test streaming telemetry shouldn't fail when the model_id is not provided for unknown reason."""
|
||||
client = use_observability(mock_chat_client)()
|
||||
client = use_instrumentation(mock_chat_client)()
|
||||
messages = [ChatMessage(role=Role.USER, text="Test")]
|
||||
span_exporter.clear()
|
||||
# Collect all yielded updates
|
||||
@@ -329,7 +329,7 @@ def test_prepend_user_agent_with_none_value():
|
||||
assert AGENT_FRAMEWORK_USER_AGENT in str(result["User-Agent"])
|
||||
|
||||
|
||||
# region Test use_agent_observability decorator
|
||||
# region Test use_agent_instrumentation decorator
|
||||
|
||||
|
||||
def test_agent_decorator_with_valid_class():
|
||||
@@ -337,7 +337,7 @@ def test_agent_decorator_with_valid_class():
|
||||
|
||||
# Create a mock class with the required methods
|
||||
class MockChatClientAgent:
|
||||
AGENT_SYSTEM_NAME = "test_agent_system"
|
||||
AGENT_PROVIDER_NAME = "test_agent_system"
|
||||
|
||||
def __init__(self):
|
||||
self.id = "test_agent_id"
|
||||
@@ -358,7 +358,7 @@ def test_agent_decorator_with_valid_class():
|
||||
return AgentThread()
|
||||
|
||||
# Apply the decorator
|
||||
decorated_class = use_agent_observability(MockChatClientAgent)
|
||||
decorated_class = use_agent_instrumentation(MockChatClientAgent)
|
||||
|
||||
assert hasattr(decorated_class, OPEN_TELEMETRY_AGENT_MARKER)
|
||||
|
||||
@@ -367,19 +367,19 @@ def test_agent_decorator_with_missing_methods():
|
||||
"""Test that agent decorator handles classes missing required methods gracefully."""
|
||||
|
||||
class MockAgent:
|
||||
AGENT_SYSTEM_NAME = "test_agent_system"
|
||||
AGENT_PROVIDER_NAME = "test_agent_system"
|
||||
|
||||
# Apply the decorator - should not raise an error
|
||||
with pytest.raises(AgentInitializationError):
|
||||
use_agent_observability(MockAgent)
|
||||
use_agent_instrumentation(MockAgent)
|
||||
|
||||
|
||||
def test_agent_decorator_with_partial_methods():
|
||||
"""Test agent decorator when only one method is present."""
|
||||
from agent_framework.observability import use_agent_observability
|
||||
from agent_framework.observability import use_agent_instrumentation
|
||||
|
||||
class MockAgent:
|
||||
AGENT_SYSTEM_NAME = "test_agent_system"
|
||||
AGENT_PROVIDER_NAME = "test_agent_system"
|
||||
|
||||
def __init__(self):
|
||||
self.id = "test_agent_id"
|
||||
@@ -390,7 +390,7 @@ def test_agent_decorator_with_partial_methods():
|
||||
return Mock()
|
||||
|
||||
with pytest.raises(AgentInitializationError):
|
||||
use_agent_observability(MockAgent)
|
||||
use_agent_instrumentation(MockAgent)
|
||||
|
||||
|
||||
# region Test agent telemetry decorator with mock agent
|
||||
@@ -401,7 +401,7 @@ def mock_chat_agent():
|
||||
"""Create a mock chat client agent for testing."""
|
||||
|
||||
class MockChatClientAgent:
|
||||
AGENT_SYSTEM_NAME = "test_agent_system"
|
||||
AGENT_PROVIDER_NAME = "test_agent_system"
|
||||
|
||||
def __init__(self):
|
||||
self.id = "test_agent_id"
|
||||
@@ -433,7 +433,7 @@ async def test_agent_instrumentation_enabled(
|
||||
):
|
||||
"""Test that when agent diagnostics are enabled, telemetry is applied."""
|
||||
|
||||
agent = use_agent_observability(mock_chat_agent)()
|
||||
agent = use_agent_instrumentation(mock_chat_agent)()
|
||||
|
||||
span_exporter.clear()
|
||||
response = await agent.run("Test message")
|
||||
@@ -457,8 +457,8 @@ async def test_agent_instrumentation_enabled(
|
||||
async def test_agent_streaming_response_with_diagnostics_enabled_via_decorator(
|
||||
mock_chat_agent: AgentProtocol, span_exporter: InMemorySpanExporter, enable_sensitive_data
|
||||
):
|
||||
"""Test agent streaming telemetry through the use_agent_observability decorator."""
|
||||
agent = use_agent_observability(mock_chat_agent)()
|
||||
"""Test agent streaming telemetry through the use_agent_instrumentation decorator."""
|
||||
agent = use_agent_instrumentation(mock_chat_agent)()
|
||||
span_exporter.clear()
|
||||
updates = []
|
||||
async for update in agent.run_stream("Test message"):
|
||||
@@ -522,3 +522,393 @@ async def test_function_call_with_error_handling(span_exporter: InMemorySpanExpo
|
||||
exception_message = exception_event.attributes["exception.message"]
|
||||
assert isinstance(exception_message, str)
|
||||
assert "Function execution failed" in exception_message
|
||||
|
||||
|
||||
# region Test OTEL environment variable parsing
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Skipping OTLP exporter tests - optional dependency not installed by default",
|
||||
)
|
||||
def test_get_exporters_from_env_with_grpc_endpoint(monkeypatch):
|
||||
"""Test _get_exporters_from_env with OTEL_EXPORTER_OTLP_ENDPOINT (gRPC)."""
|
||||
from agent_framework.observability import _get_exporters_from_env
|
||||
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc")
|
||||
|
||||
exporters = _get_exporters_from_env()
|
||||
|
||||
# Should return 3 exporters (trace, metrics, logs)
|
||||
assert len(exporters) == 3
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Skipping OTLP exporter tests - optional dependency not installed by default",
|
||||
)
|
||||
def test_get_exporters_from_env_with_http_endpoint(monkeypatch):
|
||||
"""Test _get_exporters_from_env with OTEL_EXPORTER_OTLP_ENDPOINT (HTTP)."""
|
||||
from agent_framework.observability import _get_exporters_from_env
|
||||
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http")
|
||||
|
||||
exporters = _get_exporters_from_env()
|
||||
|
||||
# Should return 3 exporters (trace, metrics, logs)
|
||||
assert len(exporters) == 3
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Skipping OTLP exporter tests - optional dependency not installed by default",
|
||||
)
|
||||
def test_get_exporters_from_env_with_individual_endpoints(monkeypatch):
|
||||
"""Test _get_exporters_from_env with individual signal endpoints."""
|
||||
from agent_framework.observability import _get_exporters_from_env
|
||||
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://localhost:4317")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "http://localhost:4318")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", "http://localhost:4319")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc")
|
||||
|
||||
exporters = _get_exporters_from_env()
|
||||
|
||||
# Should return 3 exporters (trace, metrics, logs)
|
||||
assert len(exporters) == 3
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Skipping OTLP exporter tests - optional dependency not installed by default",
|
||||
)
|
||||
def test_get_exporters_from_env_with_headers(monkeypatch):
|
||||
"""Test _get_exporters_from_env with OTEL_EXPORTER_OTLP_HEADERS."""
|
||||
from agent_framework.observability import _get_exporters_from_env
|
||||
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_HEADERS", "key1=value1,key2=value2")
|
||||
|
||||
exporters = _get_exporters_from_env()
|
||||
|
||||
# Should return 3 exporters with headers
|
||||
assert len(exporters) == 3
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Skipping OTLP exporter tests - optional dependency not installed by default",
|
||||
)
|
||||
def test_get_exporters_from_env_with_signal_specific_headers(monkeypatch):
|
||||
"""Test _get_exporters_from_env with signal-specific headers."""
|
||||
from agent_framework.observability import _get_exporters_from_env
|
||||
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://localhost:4317")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_HEADERS", "trace-key=trace-value")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc")
|
||||
|
||||
exporters = _get_exporters_from_env()
|
||||
|
||||
# Should have at least the traces exporter
|
||||
assert len(exporters) >= 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Skipping OTLP exporter tests - optional dependency not installed by default",
|
||||
)
|
||||
def test_get_exporters_from_env_without_env_vars(monkeypatch):
|
||||
"""Test _get_exporters_from_env returns empty list when no env vars set."""
|
||||
from agent_framework.observability import _get_exporters_from_env
|
||||
|
||||
# Clear all OTEL env vars
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
exporters = _get_exporters_from_env()
|
||||
|
||||
# Should return empty list
|
||||
assert len(exporters) == 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Skipping OTLP exporter tests - optional dependency not installed by default",
|
||||
)
|
||||
def test_get_exporters_from_env_missing_grpc_dependency(monkeypatch):
|
||||
"""Test _get_exporters_from_env raises ImportError when gRPC exporters not installed."""
|
||||
|
||||
from agent_framework.observability import _get_exporters_from_env
|
||||
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317")
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc")
|
||||
|
||||
# Mock the import to raise ImportError
|
||||
original_import = __builtins__.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if "opentelemetry.exporter.otlp.proto.grpc" in name:
|
||||
raise ImportError("No module named 'opentelemetry.exporter.otlp.proto.grpc'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(__builtins__, "__import__", mock_import)
|
||||
|
||||
with pytest.raises(ImportError, match="opentelemetry-exporter-otlp-proto-grpc"):
|
||||
_get_exporters_from_env()
|
||||
|
||||
|
||||
# region Test create_resource
|
||||
|
||||
|
||||
def test_create_resource_from_env(monkeypatch):
|
||||
"""Test create_resource reads OTEL environment variables."""
|
||||
from agent_framework.observability import create_resource
|
||||
|
||||
monkeypatch.setenv("OTEL_SERVICE_NAME", "test-service")
|
||||
monkeypatch.setenv("OTEL_SERVICE_VERSION", "1.0.0")
|
||||
monkeypatch.setenv("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=production,host.name=server1")
|
||||
|
||||
resource = create_resource()
|
||||
|
||||
assert resource.attributes["service.name"] == "test-service"
|
||||
assert resource.attributes["service.version"] == "1.0.0"
|
||||
assert resource.attributes["deployment.environment"] == "production"
|
||||
assert resource.attributes["host.name"] == "server1"
|
||||
|
||||
|
||||
def test_create_resource_with_parameters_override_env(monkeypatch):
|
||||
"""Test create_resource parameters override environment variables."""
|
||||
from agent_framework.observability import create_resource
|
||||
|
||||
monkeypatch.setenv("OTEL_SERVICE_NAME", "env-service")
|
||||
monkeypatch.setenv("OTEL_SERVICE_VERSION", "0.1.0")
|
||||
|
||||
resource = create_resource(service_name="param-service", service_version="2.0.0")
|
||||
|
||||
# Parameters should override env vars
|
||||
assert resource.attributes["service.name"] == "param-service"
|
||||
assert resource.attributes["service.version"] == "2.0.0"
|
||||
|
||||
|
||||
def test_create_resource_with_custom_attributes(monkeypatch):
|
||||
"""Test create_resource accepts custom attributes."""
|
||||
from agent_framework.observability import create_resource
|
||||
|
||||
resource = create_resource(custom_attr="custom_value", another_attr=123)
|
||||
|
||||
assert resource.attributes["custom_attr"] == "custom_value"
|
||||
assert resource.attributes["another_attr"] == 123
|
||||
|
||||
|
||||
# region Test _create_otlp_exporters
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Skipping OTLP exporter tests - optional dependency not installed by default",
|
||||
)
|
||||
def test_create_otlp_exporters_grpc_with_single_endpoint():
|
||||
"""Test _create_otlp_exporters creates gRPC exporters with single endpoint."""
|
||||
from agent_framework.observability import _create_otlp_exporters
|
||||
|
||||
exporters = _create_otlp_exporters(endpoint="http://localhost:4317", protocol="grpc")
|
||||
|
||||
# Should return 3 exporters (trace, metrics, logs)
|
||||
assert len(exporters) == 3
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Skipping OTLP exporter tests - optional dependency not installed by default",
|
||||
)
|
||||
def test_create_otlp_exporters_http_with_single_endpoint():
|
||||
"""Test _create_otlp_exporters creates HTTP exporters with single endpoint."""
|
||||
from agent_framework.observability import _create_otlp_exporters
|
||||
|
||||
exporters = _create_otlp_exporters(endpoint="http://localhost:4318", protocol="http")
|
||||
|
||||
# Should return 3 exporters (trace, metrics, logs)
|
||||
assert len(exporters) == 3
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Skipping OTLP exporter tests - optional dependency not installed by default",
|
||||
)
|
||||
def test_create_otlp_exporters_with_individual_endpoints():
|
||||
"""Test _create_otlp_exporters with individual signal endpoints."""
|
||||
from agent_framework.observability import _create_otlp_exporters
|
||||
|
||||
exporters = _create_otlp_exporters(
|
||||
protocol="grpc",
|
||||
traces_endpoint="http://localhost:4317",
|
||||
metrics_endpoint="http://localhost:4318",
|
||||
logs_endpoint="http://localhost:4319",
|
||||
)
|
||||
|
||||
# Should return 3 exporters
|
||||
assert len(exporters) == 3
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Skipping OTLP exporter tests - optional dependency not installed by default",
|
||||
)
|
||||
def test_create_otlp_exporters_with_headers():
|
||||
"""Test _create_otlp_exporters with headers."""
|
||||
from agent_framework.observability import _create_otlp_exporters
|
||||
|
||||
exporters = _create_otlp_exporters(
|
||||
endpoint="http://localhost:4317", protocol="grpc", headers={"Authorization": "Bearer token"}
|
||||
)
|
||||
|
||||
# Should return 3 exporters with headers
|
||||
assert len(exporters) == 3
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Skipping OTLP exporter tests - optional dependency not installed by default",
|
||||
)
|
||||
def test_create_otlp_exporters_grpc_missing_dependency():
|
||||
"""Test _create_otlp_exporters raises ImportError when gRPC exporters not installed."""
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent_framework.observability import _create_otlp_exporters
|
||||
|
||||
# Mock the import to raise ImportError
|
||||
with (
|
||||
patch.dict(sys.modules, {"opentelemetry.exporter.otlp.proto.grpc.trace_exporter": None}),
|
||||
pytest.raises(ImportError, match="opentelemetry-exporter-otlp-proto-grpc"),
|
||||
):
|
||||
_create_otlp_exporters(endpoint="http://localhost:4317", protocol="grpc")
|
||||
|
||||
|
||||
# region Test configure_otel_providers with views
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Skipping OTLP exporter tests - optional dependency not installed by default",
|
||||
)
|
||||
def test_configure_otel_providers_with_views(monkeypatch):
|
||||
"""Test configure_otel_providers accepts views parameter."""
|
||||
from opentelemetry.sdk.metrics import View
|
||||
from opentelemetry.sdk.metrics.view import DropAggregation
|
||||
|
||||
from agent_framework.observability import configure_otel_providers
|
||||
|
||||
# Clear all OTEL env vars
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
# Create a view that drops all metrics
|
||||
views = [View(instrument_name="*", aggregation=DropAggregation())]
|
||||
|
||||
# Should not raise an error
|
||||
configure_otel_providers(views=views)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
True,
|
||||
reason="Skipping OTLP exporter tests - optional dependency not installed by default",
|
||||
)
|
||||
def test_configure_otel_providers_without_views(monkeypatch):
|
||||
"""Test configure_otel_providers works without views parameter."""
|
||||
from agent_framework.observability import configure_otel_providers
|
||||
|
||||
# Clear all OTEL env vars
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
# Should not raise an error with default empty views
|
||||
configure_otel_providers()
|
||||
|
||||
|
||||
# region Test console exporters opt-in
|
||||
|
||||
|
||||
def test_console_exporters_opt_in_false(monkeypatch):
|
||||
"""Test console exporters are not added when ENABLE_CONSOLE_EXPORTERS is false."""
|
||||
from agent_framework.observability import ObservabilitySettings
|
||||
|
||||
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "false")
|
||||
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
|
||||
|
||||
settings = ObservabilitySettings(env_file_path="test.env")
|
||||
assert settings.enable_console_exporters is False
|
||||
|
||||
|
||||
def test_console_exporters_opt_in_true(monkeypatch):
|
||||
"""Test console exporters are added when ENABLE_CONSOLE_EXPORTERS is true."""
|
||||
from agent_framework.observability import ObservabilitySettings
|
||||
|
||||
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
|
||||
|
||||
settings = ObservabilitySettings(env_file_path="test.env")
|
||||
assert settings.enable_console_exporters is True
|
||||
|
||||
|
||||
def test_console_exporters_default_false(monkeypatch):
|
||||
"""Test console exporters default to False when not set."""
|
||||
from agent_framework.observability import ObservabilitySettings
|
||||
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
|
||||
settings = ObservabilitySettings(env_file_path="test.env")
|
||||
assert settings.enable_console_exporters is False
|
||||
|
||||
|
||||
# region Test _parse_headers helper
|
||||
|
||||
|
||||
def test_parse_headers_valid():
|
||||
"""Test _parse_headers with valid header string."""
|
||||
from agent_framework.observability import _parse_headers
|
||||
|
||||
headers = _parse_headers("key1=value1,key2=value2")
|
||||
assert headers == {"key1": "value1", "key2": "value2"}
|
||||
|
||||
|
||||
def test_parse_headers_with_spaces():
|
||||
"""Test _parse_headers handles spaces around keys and values."""
|
||||
from agent_framework.observability import _parse_headers
|
||||
|
||||
headers = _parse_headers("key1 = value1 , key2 = value2 ")
|
||||
assert headers == {"key1": "value1", "key2": "value2"}
|
||||
|
||||
|
||||
def test_parse_headers_empty_string():
|
||||
"""Test _parse_headers with empty string."""
|
||||
from agent_framework.observability import _parse_headers
|
||||
|
||||
headers = _parse_headers("")
|
||||
assert headers == {}
|
||||
|
||||
|
||||
def test_parse_headers_invalid_format():
|
||||
"""Test _parse_headers ignores invalid pairs."""
|
||||
from agent_framework.observability import _parse_headers
|
||||
|
||||
headers = _parse_headers("key1=value1,invalid,key2=value2")
|
||||
# Should only include valid pairs
|
||||
assert headers == {"key1": "value1", "key2": "value2"}
|
||||
|
||||
@@ -832,7 +832,7 @@ def test_create_streaming_response_content_with_mcp_approval_request() -> None:
|
||||
assert fa.function_call.name == "do_stream_action"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_otel", [False], indirect=True)
|
||||
@pytest.mark.parametrize("enable_instrumentation", [False], indirect=True)
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True)
|
||||
async def test_end_to_end_mcp_approval_flow(span_exporter) -> None:
|
||||
"""End-to-end mocked test:
|
||||
|
||||
@@ -22,5 +22,5 @@ def test_datetime_in_tool_results() -> None:
|
||||
result = _to_otel_part(content)
|
||||
parsed = json.loads(result["response"])
|
||||
|
||||
# Datetime should be converted to string
|
||||
assert isinstance(parsed["timestamp"], str)
|
||||
# Datetime should be converted to string in the result field
|
||||
assert isinstance(parsed["result"]["timestamp"], str)
|
||||
|
||||
@@ -229,8 +229,10 @@ async def test_trace_context_handling(span_exporter: InMemorySpanExporter) -> No
|
||||
assert processing_span.attributes.get("message.payload_type") == "str"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_otel", [False], indirect=True)
|
||||
async def test_trace_context_disabled_when_tracing_disabled(enable_otel, span_exporter: InMemorySpanExporter) -> None:
|
||||
@pytest.mark.parametrize("enable_instrumentation", [False], indirect=True)
|
||||
async def test_trace_context_disabled_when_tracing_disabled(
|
||||
enable_instrumentation, span_exporter: InMemorySpanExporter
|
||||
) -> None:
|
||||
"""Test that no trace context is added when tracing is disabled."""
|
||||
# Tracing should be disabled by default
|
||||
executor = MockExecutor("test-executor")
|
||||
@@ -433,7 +435,7 @@ async def test_workflow_error_handling_in_tracing(span_exporter: InMemorySpanExp
|
||||
assert workflow_span.status.status_code.name == "ERROR"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_otel", [False], indirect=True)
|
||||
@pytest.mark.parametrize("enable_instrumentation", [False], indirect=True)
|
||||
async def test_message_trace_context_serialization(span_exporter: InMemorySpanExporter) -> None:
|
||||
"""Test that message trace context is properly serialized/deserialized."""
|
||||
ctx = InProcRunnerContext(InMemoryCheckpointStorage())
|
||||
|
||||
Reference in New Issue
Block a user