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:
Eduard van Valkenburg
2025-12-16 07:56:30 +01:00
committed by GitHub
Unverified
parent 3c379718e9
commit 3139347526
46 changed files with 5823 additions and 4615 deletions
@@ -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."""
-1
View File
@@ -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",
+28 -10
View File
@@ -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())