diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index a214fefe6..023d7530a 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -564,6 +564,30 @@ with ExecuteToolScope.start( scope.record_response(result) ``` +For remote tools, pass a full endpoint URL to retain its path and query in `url.full`: + +```python +from microsoft.opentelemetry.a365.core import ServiceEndpoint, ToolCallDetails + +details = ToolCallDetails( + tool_name="get_weather", + endpoint=ServiceEndpoint.from_url("https://tools.example.test:8443/api/weather"), +) +``` + +`ServiceEndpoint.from_url()` requires an absolute URL with a hostname and a valid port. +It derives `server.address` and `server.port` while preserving the original full URL. +As before, port `443` is omitted on execute-tool spans. +Existing `ServiceEndpoint(hostname="tools.example.test", port=8443)` calls remain valid +and do not invent or emit a full URL. A full URL can also be supplied via the optional +`ServiceEndpoint.url` field alongside the existing hostname and port. +Only execute-tool scopes emit this URL attribute; other scopes retain their existing behavior. + +**Privacy:** Full URLs are recorded as supplied, including paths and query parameters. +Do not pass URLs containing credentials, access tokens, personal data or other secrets; +sanitize them before constructing the endpoint. URL capture is not gated by +`enable_sensitive_data`. + `ToolCallDetails.arguments` and `ExecuteToolScope.record_response()` also accept typed execute-tool schema models. Typed top-level arguments and results serialize to the Agent365 JSON contract with `schema_version: "1.0"`, while raw dictionaries and strings remain supported and keep their existing behavior. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0285f1df9..edfb8cea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,11 @@ - Log blob deletion-check filesystem errors without interrupting replay of other valid Agent365 records in the same batch. Verify deletion with `stat()` so Python 3.14 does not silently treat access failures as missing blobs. + +### Features Added +- Record full endpoint URLs on Agent365 tool spans with `ServiceEndpoint.from_url()`. + ([#311](https://github.com/microsoft/opentelemetry-distro-python/pull/311)) + ### Other Changes - Update the `AZURE_OPENAI_API_VERSION` fallback version in integration tests diff --git a/src/microsoft/opentelemetry/a365/core/constants.py b/src/microsoft/opentelemetry/a365/core/constants.py index d1b5cc5b3..ed118440a 100644 --- a/src/microsoft/opentelemetry/a365/core/constants.py +++ b/src/microsoft/opentelemetry/a365/core/constants.py @@ -149,6 +149,7 @@ # --- OTel standard keys --- SERVER_ADDRESS_KEY = "server.address" SERVER_PORT_KEY = "server.port" +URL_FULL_KEY = "url.full" SERVICE_NAME_KEY = "service.name" # --- Custom span keys --- diff --git a/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py b/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py index 72593d26a..b2f19b390 100644 --- a/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py +++ b/src/microsoft/opentelemetry/a365/core/execute_tool_scope.py @@ -20,6 +20,7 @@ GEN_AI_TOOL_TYPE_KEY, SERVER_ADDRESS_KEY, SERVER_PORT_KEY, + URL_FULL_KEY, USER_EMAIL_KEY, USER_ID_KEY, USER_NAME_KEY, @@ -149,6 +150,7 @@ def __init__( if endpoint: self.set_tag_maybe(SERVER_ADDRESS_KEY, endpoint.hostname) + self.set_tag_maybe(URL_FULL_KEY, endpoint.url) if endpoint.port and endpoint.port != 443: self.set_tag_maybe(SERVER_PORT_KEY, endpoint.port) diff --git a/src/microsoft/opentelemetry/a365/core/models/service_endpoint.py b/src/microsoft/opentelemetry/a365/core/models/service_endpoint.py index 5382ab6cc..d71f4527c 100644 --- a/src/microsoft/opentelemetry/a365/core/models/service_endpoint.py +++ b/src/microsoft/opentelemetry/a365/core/models/service_endpoint.py @@ -2,14 +2,33 @@ # Licensed under the MIT License. from dataclasses import dataclass +from urllib.parse import urlsplit @dataclass class ServiceEndpoint: - """Represents a service endpoint with hostname and optional port.""" + """Represents a service endpoint with hostname, optional port and full URL.""" hostname: str """The hostname of the service endpoint.""" port: int | None = None """The port of the service endpoint.""" + + url: str | None = None + """Optional full endpoint URL. Only supply URLs safe to record in telemetry.""" + + @classmethod + def from_url(cls, url: str) -> "ServiceEndpoint": + """Create an endpoint from an absolute URL, retaining its path and query. + + Raises: + ValueError: If the URL lacks a scheme or hostname, or has an invalid port. + """ + parsed = urlsplit(url) + if not parsed.scheme or not parsed.hostname: + raise ValueError("Service endpoint URL must be absolute and include a hostname.") + port = parsed.port + if port is None: + port = {"http": 80, "https": 443}.get(parsed.scheme.lower()) + return cls(hostname=parsed.hostname, port=port, url=url) diff --git a/tests/a365/integration/agentframework/test_agentframework_trace_processor.py b/tests/a365/integration/agentframework/test_agentframework_trace_processor.py index 9710d349b..403014782 100644 --- a/tests/a365/integration/agentframework/test_agentframework_trace_processor.py +++ b/tests/a365/integration/agentframework/test_agentframework_trace_processor.py @@ -19,7 +19,7 @@ try: from agent_framework._agents import RawAgent from agent_framework._tools import tool as ai_function - from agent_framework.openai import OpenAIChatClient + from agent_framework.openai import OpenAIChatCompletionClient except ImportError as _e: pytest.skip( f"AgentFramework library and dependencies required for integration tests: {_e}", @@ -47,10 +47,11 @@ def test_agentframework_trace_processor_integration(self, distro_exporter, azure """Test AgentFramework trace processor with real Azure OpenAI call.""" # Create Azure OpenAI ChatClient - chat_client = OpenAIChatClient( + chat_client = OpenAIChatCompletionClient( model=azure_openai_config["deployment"], azure_endpoint=azure_openai_config["endpoint"], api_key=azure_openai_config["api_key"], + api_version=azure_openai_config["api_version"], ) # Create agent framework agent @@ -93,10 +94,11 @@ def test_agentframework_trace_processor_with_tool_calls( """Test AgentFramework trace processor with tool calls.""" # Create Azure OpenAI ChatClient - chat_client = OpenAIChatClient( + chat_client = OpenAIChatCompletionClient( model=azure_openai_config["deployment"], azure_endpoint=azure_openai_config["endpoint"], api_key=azure_openai_config["api_key"], + api_version=azure_openai_config["api_version"], ) # Create agent framework agent diff --git a/tests/a365/integration/agentframework/test_message_format.py b/tests/a365/integration/agentframework/test_message_format.py index 8a27f49fe..d279a0cea 100644 --- a/tests/a365/integration/agentframework/test_message_format.py +++ b/tests/a365/integration/agentframework/test_message_format.py @@ -21,7 +21,7 @@ try: from agent_framework._agents import RawAgent from agent_framework._tools import tool - from agent_framework.openai import OpenAIChatClient + from agent_framework.openai import OpenAIChatCompletionClient except ImportError: pytest.skip( "AgentFramework library and dependencies required for integration tests", @@ -54,12 +54,13 @@ class TestAgentFrameworkMessageFormat: and verify the A365 structured message format.""" @pytest.fixture - def chat_client(self, azure_openai_config: dict[str, Any]) -> OpenAIChatClient: + def chat_client(self, azure_openai_config: dict[str, Any]) -> OpenAIChatCompletionClient: """Create a real Azure OpenAI chat client.""" - return OpenAIChatClient( + return OpenAIChatCompletionClient( model=azure_openai_config["deployment"], azure_endpoint=azure_openai_config["endpoint"], api_key=azure_openai_config["api_key"], + api_version=azure_openai_config["api_version"], ) def _find_chat_spans(self, distro_exporter) -> list[ReadableSpan]: @@ -72,7 +73,7 @@ def _find_chat_spans(self, distro_exporter) -> list[ReadableSpan]: return [s for s in distro_exporter.spans if s.attributes and GEN_AI_INPUT_MESSAGES_KEY in s.attributes] @pytest.mark.asyncio - async def test_simple_chat_message_mapping(self, distro_exporter, chat_client: OpenAIChatClient) -> None: + async def test_simple_chat_message_mapping(self, distro_exporter, chat_client: OpenAIChatCompletionClient) -> None: """Simple chat: verify exported spans contain structured A365 messages after enrichment (no manual mapper call).""" agent = RawAgent( @@ -124,7 +125,7 @@ async def test_simple_chat_message_mapping(self, distro_exporter, chat_client: O print(f"\n=== Enriched output ===\n{json.dumps(output_data, indent=2)}") @pytest.mark.asyncio - async def test_tool_call_message_mapping(self, distro_exporter, chat_client: OpenAIChatClient) -> None: + async def test_tool_call_message_mapping(self, distro_exporter, chat_client: OpenAIChatCompletionClient) -> None: """Tool-calling chat: verify tool_call and tool_call_response parts survive enrichment in exported spans.""" agent = RawAgent( diff --git a/tests/a365/integration/agentframework/test_observability_pipeline.py b/tests/a365/integration/agentframework/test_observability_pipeline.py index a00e17821..d6aa2a463 100644 --- a/tests/a365/integration/agentframework/test_observability_pipeline.py +++ b/tests/a365/integration/agentframework/test_observability_pipeline.py @@ -23,7 +23,7 @@ try: from agent_framework._agents import RawAgent from agent_framework._tools import tool as ai_function - from agent_framework.openai import OpenAIChatClient + from agent_framework.openai import OpenAIChatCompletionClient except ImportError: pytest.skip( "AgentFramework library and dependencies required for integration tests", @@ -87,12 +87,13 @@ class TestAgentFrameworkObservabilityPipeline: """ @pytest.fixture - def chat_client(self, azure_openai_config: dict) -> OpenAIChatClient: + def chat_client(self, azure_openai_config: dict) -> OpenAIChatCompletionClient: """Create a real Azure OpenAI chat client.""" - return OpenAIChatClient( + return OpenAIChatCompletionClient( model=azure_openai_config["deployment"], azure_endpoint=azure_openai_config["endpoint"], api_key=azure_openai_config["api_key"], + api_version=azure_openai_config["api_version"], ) @pytest.fixture @@ -119,7 +120,7 @@ def _flush_and_collect(self, distro_exporter) -> list[ReadableSpan]: async def test_pipeline_invoke_agent_with_tool_call( # pylint: disable=too-many-statements self, distro_exporter, - chat_client: OpenAIChatClient, + chat_client: OpenAIChatCompletionClient, agent_details: AgentDetails, agent365_config: dict, ) -> None: @@ -262,7 +263,7 @@ async def test_pipeline_invoke_agent_with_tool_call( # pylint: disable=too-many async def test_pipeline_invoke_agent_simple_inference( self, distro_exporter, - chat_client: OpenAIChatClient, + chat_client: OpenAIChatCompletionClient, agent_details: AgentDetails, agent365_config: dict, ) -> None: diff --git a/tests/a365/test_execute_tool_scope.py b/tests/a365/test_execute_tool_scope.py index da18c4b6b..340f87ecb 100644 --- a/tests/a365/test_execute_tool_scope.py +++ b/tests/a365/test_execute_tool_scope.py @@ -6,6 +6,7 @@ import unittest from unittest.mock import patch +import pytest from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider @@ -259,3 +260,77 @@ def test_unserializable_typed_payloads_are_safe_when_telemetry_is_disabled(self) self.assertIsNone(scope._span) self.assertIsNone(raw_scope._span) + + +@pytest.mark.parametrize( + "url,host,port", + [ + ("https://tools.example.test/api/search?q=weather", "tools.example.test", None), + ("https://tools.example.test:8443/api", "tools.example.test", 8443), + ("http://tools.example.test/api", "tools.example.test", 80), + ("https://[::1]:8443/api", "::1", 8443), + ], +) +def test_execute_tool_records_full_endpoint_url(monkeypatch, url, host, port): + from microsoft.opentelemetry.a365.core import ServiceEndpoint + + provider = TracerProvider() + monkeypatch.setenv("ENABLE_OBSERVABILITY", "true") + monkeypatch.setattr(ExecuteToolScope, "_tracer", provider.get_tracer("Agent365Sdk")) + try: + with ExecuteToolScope.start( + Request(), + ToolCallDetails(tool_name="search", endpoint=ServiceEndpoint.from_url(url)), + AgentDetails("agent"), + ) as scope: + attrs = dict(scope._span.attributes) + assert attrs["url.full"] == url + assert attrs["server.address"] == host + if port is None: + assert "server.port" not in attrs + else: + assert attrs["server.port"] == port + finally: + provider.shutdown() + + +def test_execute_tool_preserves_hostname_only_endpoints(monkeypatch): + from microsoft.opentelemetry.a365.core import ServiceEndpoint + + provider = TracerProvider() + monkeypatch.setenv("ENABLE_OBSERVABILITY", "true") + monkeypatch.setattr(ExecuteToolScope, "_tracer", provider.get_tracer("Agent365Sdk")) + try: + with ExecuteToolScope.start( + Request(), + ToolCallDetails(tool_name="search", endpoint=ServiceEndpoint("tools.example.test", 8443)), + AgentDetails("agent"), + ) as scope: + attrs = dict(scope._span.attributes) + assert attrs["server.address"] == "tools.example.test" + assert attrs["server.port"] == 8443 + assert "url.full" not in attrs + finally: + provider.shutdown() + + +@pytest.mark.parametrize( + "url", + [ + pytest.param("", id="empty"), + pytest.param(" \t\r\n", id="whitespace-only"), + pytest.param("relative/path", id="relative-path"), + pytest.param("//tools.example.test/api", id="missing-scheme"), + pytest.param("https:///api", id="missing-host"), + pytest.param("https://:8443/api", id="port-without-host"), + pytest.param("https://[::1/api", id="malformed-ipv6"), + pytest.param("https://tools.example.test:invalid/api", id="nonnumeric-port"), + pytest.param("https://tools.example.test:-1/api", id="negative-port"), + pytest.param("https://tools.example.test:65536/api", id="out-of-range-port"), + ], +) +def test_service_endpoint_rejects_invalid_absolute_urls(url): + from microsoft.opentelemetry.a365.core import ServiceEndpoint + + with pytest.raises(ValueError): + ServiceEndpoint.from_url(url)