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
Original file line number Diff line number Diff line change
Expand Up @@ -277,17 +277,31 @@ def __init__(
self.project_id = project_id

def on_start(self, span: Any, parent_context: Any = None) -> None: # pylint: disable=unused-argument
if self.agent_name:
span.set_attribute(_ATTR_GEN_AI_AGENT_NAME, self.agent_name)
if self.agent_version:
span.set_attribute(_ATTR_GEN_AI_AGENT_VERSION, self.agent_version)
if self.agent_id:
span.set_attribute(_ATTR_GEN_AI_AGENT_ID, self.agent_id)
if self.project_id:
span.set_attribute(_ATTR_FOUNDRY_PROJECT_ID, self.project_id)

def _on_ending(self, span: Any) -> None: # pylint: disable=unused-argument
pass
def _on_ending(self, span: Any) -> None:
# Set agent identity attributes at span end so they cannot be
# overwritten by underlying frameworks (e.g. LangChain, Semantic Kernel).
#
# Workaround: opentelemetry-sdk <=1.40.0 sets _end_time before calling
# _on_ending, which causes set_attribute() to silently no-op despite the
# spec requiring mutability during OnEnding. We write to _attributes
# directly until the SDK is fixed. The try/except guards against future
# SDK changes that may rename or remove the internal field.
# TODO: switch to span.set_attribute() once the SDK honours the spec.
attrs = getattr(span, "_attributes", None)
if attrs is None:
return
try:
if self.agent_name:
attrs[_ATTR_GEN_AI_AGENT_NAME] = self.agent_name
if self.agent_version:
attrs[_ATTR_GEN_AI_AGENT_VERSION] = self.agent_version
if self.agent_id:
attrs[_ATTR_GEN_AI_AGENT_ID] = self.agent_id
except Exception: # pylint: disable=broad-exception-caught
logger.debug("Failed to enrich span attributes in _on_ending", exc_info=True)
Comment thread
needuv marked this conversation as resolved.

def on_end(self, span: Any) -> None: # pylint: disable=unused-argument
pass
Expand Down
101 changes: 101 additions & 0 deletions sdk/agentserver/azure-ai-agentserver-core/tests/test_tracing.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,34 @@
import os
from unittest import mock

from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, SpanExporter, SpanExportResult
from opentelemetry.sdk.resources import Resource

from azure.ai.agentserver.core import AgentServerHost
from azure.ai.agentserver.core._config import (
resolve_agent_name,
resolve_agent_version,
resolve_appinsights_connection_string,
)
from azure.ai.agentserver.core._tracing import _FoundryEnrichmentSpanProcessor


class _CollectorExporter(SpanExporter):
"""In-memory span collector for tests."""

def __init__(self):
self.spans = []

def export(self, spans):
self.spans.extend(spans)
return SpanExportResult.SUCCESS

def shutdown(self):
return True

def force_flush(self, timeout_millis=30000):
return True
# ------------------------------------------------------------------ #
# Tracing enabled / disabled
# ------------------------------------------------------------------ #
Expand Down Expand Up @@ -137,6 +156,88 @@ def test_constructor_passes_connection_string(self) -> None:
mock_configure.assert_called_once_with(connection_string="InstrumentationKey=ctor")


# ------------------------------------------------------------------ #
# FoundryEnrichmentSpanProcessor: attribute timing
# ------------------------------------------------------------------ #


class TestFoundryEnrichmentSpanProcessor:
"""Agent identity attributes are set in _on_ending so that underlying
frameworks (LangChain, Semantic Kernel, etc.) cannot overwrite them.

Tests use real OTel spans with an in-memory exporter to verify the
exported attributes end-to-end.
"""

@staticmethod
def _create_provider(processor):
"""Return (TracerProvider, _CollectorExporter) wired with *processor*."""
collector = _CollectorExporter()
provider = TracerProvider(resource=Resource.create({}))
provider.add_span_processor(processor)
provider.add_span_processor(SimpleSpanProcessor(collector))
return provider, collector

def test_agent_attrs_present_on_exported_span(self) -> None:
proc = _FoundryEnrichmentSpanProcessor(
agent_name="my-agent", agent_version="1.0",
agent_id="my-agent:1.0", project_id="proj-123",
)
provider, collector = self._create_provider(proc)
tracer = provider.get_tracer("test")

with tracer.start_as_current_span("span"):
pass

attrs = dict(collector.spans[0].attributes)
assert attrs["gen_ai.agent.name"] == "my-agent"
assert attrs["gen_ai.agent.version"] == "1.0"
assert attrs["gen_ai.agent.id"] == "my-agent:1.0"
assert attrs["microsoft.foundry.project.id"] == "proj-123"

def test_agent_attrs_survive_framework_overwrite(self) -> None:
"""A framework setting agent attrs mid-span must not win."""
proc = _FoundryEnrichmentSpanProcessor(
agent_name="my-agent", agent_version="1.0",
agent_id="my-agent:1.0", project_id="proj-123",
)
provider, collector = self._create_provider(proc)
tracer = provider.get_tracer("test")

with tracer.start_as_current_span("span") as span:
span.set_attribute("gen_ai.agent.name", "framework-agent")
span.set_attribute("gen_ai.agent.id", "framework-agent:0.1")

attrs = dict(collector.spans[0].attributes)
assert attrs["gen_ai.agent.name"] == "my-agent"
assert attrs["gen_ai.agent.id"] == "my-agent:1.0"

def test_none_fields_are_skipped(self) -> None:
proc = _FoundryEnrichmentSpanProcessor(
agent_name=None, agent_version=None,
agent_id=None, project_id=None,
)
provider, collector = self._create_provider(proc)
tracer = provider.get_tracer("test")

with tracer.start_as_current_span("span"):
pass

attrs = dict(collector.spans[0].attributes)
assert "gen_ai.agent.name" not in attrs
assert "gen_ai.agent.version" not in attrs
assert "gen_ai.agent.id" not in attrs
assert "microsoft.foundry.project.id" not in attrs

def test_no_crash_when_span_lacks_attributes(self) -> None:
"""If the SDK changes internals, _on_ending must not raise."""
proc = _FoundryEnrichmentSpanProcessor(
agent_name="a", agent_version="1", agent_id="a:1",
)
fake_span = object() # no _attributes at all
proc._on_ending(fake_span) # should not raise


# ------------------------------------------------------------------ #
# Agent name / version resolution with new env vars
# ------------------------------------------------------------------ #
Expand Down