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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions A365_DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/microsoft/opentelemetry/a365/core/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
2 changes: 2 additions & 0 deletions src/microsoft/opentelemetry/a365/core/execute_tool_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions tests/a365/integration/agentframework/test_message_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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]:
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
75 changes: 75 additions & 0 deletions tests/a365/test_execute_tool_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import unittest
from unittest.mock import patch

import pytest
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider

Expand Down Expand Up @@ -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",
Comment thread
nikhilNava marked this conversation as resolved.
Resolved
[
("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)
Loading