Skip to content

Commit ca36cd4

Browse files
singankitCopilot
andauthored
Migrate agentserver tracing from azure-monitor-opentelemetry-exporter… (#46561)
* Migrate agentserver tracing from azure-monitor-opentelemetry-exporter to microsoft-opentelemetry Replace manual Azure Monitor and OTLP exporter wiring (~100 lines) with a single use_microsoft_opentelemetry() call via the new microsoft-opentelemetry distro. The distro auto-detects exporters from environment variables. Changes: - core pyproject.toml: swap azure-monitor-opentelemetry-exporter + otlp-grpc for microsoft-opentelemetry>=0.5.0 - core _tracing.py: refactor _configure_tracing() to delegate to new _setup_distro_export() wrapper; remove 4 _setup_* functions and guard flags - Update all test mocks in core and invocations to patch the single _setup_distro_export target instead of multiple _setup_* functions - Remove azure-monitor-opentelemetry-exporter from dev_requirements.txt in both core and invocations packages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add warning log when OTel resource creation fails Log a warning in _configure_tracing() when _create_resource() returns None, making it clear that tracing will not be configured. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review comments - Fix docstring and inline comment to accurately describe logger suppression (only Azure Core HTTP logging policy, not generic OTel exporters) - Make _ensure_trace_provider idempotent via _agentserver_processors_added guard to prevent duplicate processors on repeated calls - Rename TestSetupAzureMonitor -> TestSetupDistroExport to match new impl Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add create=True to _setup_distro_export patches for min-dep compat The invocations min-dependency CI installs the published core package from PyPI, which does not yet have _setup_distro_export. Adding create=True allows the mock to succeed regardless of the installed core version. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use valid UUID in fake instrumentation keys for tests The microsoft-opentelemetry distro validates that the instrumentation key is a proper UUID. Replace 'InstrumentationKey=test' with 'InstrumentationKey=00000000-0000-0000-0000-000000000000' in all test files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix: lower microsoft-opentelemetry version to >=0.1.0b1 The previous requirement >=0.5.0 doesn't exist on PyPI. The latest available release is 0.1.0b1 (just published). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add INFO log on successful tracing configuration The previous code logged INFO for each exporter configured (e.g. 'Application Insights trace exporter configured.'). This preserves that pattern with a single INFO message when the distro setup succeeds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix: enable Azure Monitor export in distro call The microsoft-opentelemetry distro has Azure Monitor disabled by default. Pass enable_azure_monitor=True and the connection string via azure_monitor_connection_string when a connection string is provided. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add e2e span parenting test for App Insights Adds test_span_parenting_in_appinsights which verifies that a child span created inside request_span is correctly parented in App Insights: - Captures child span ID locally during handler execution - Queries dependencies table by child span ID - Follows operation_ParentId to find the parent invoke_agent span in requests table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: defer set_tracer_provider to fixture to unblock e2e tests Move module-level set_tracer_provider() calls in invocations unit tests into autouse fixtures. When pytest collects tests it imports ALL modules, even deselected ones. Module-level calls consumed the OTel Once guard before the e2e test could configure the microsoft-opentelemetry distro, causing spans to never reach App Insights. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add microsoft-opentelemetry to shared_requirements.txt The dependency analyzer requires all package dependencies to be listed in shared_requirements.txt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: resolve pylint errors in _tracing.py - Add :keyword: docstring entries for _setup_distro_export (C4758) - Use dict literal instead of dict() call (R1735) - Add :param: docstring entries for _ensure_trace_provider (C4739) - Suppress protected-access warning on idempotency guard (W0212) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add param types to _ensure_trace_provider docstring (C4740) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 59e1823 commit ca36cd4

10 files changed

Lines changed: 264 additions & 231 deletions

File tree

sdk/agentserver/azure-ai-agentserver-core/azure/ai/agentserver/core/_tracing.py

Lines changed: 91 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@
1313
1414
- Console ``StreamHandler`` on the **root** logger (so both SDK and
1515
user ``logging.info()`` calls are visible).
16-
- Suppression of noisy Azure SDK / OTel exporter loggers.
17-
- Azure Monitor trace + log export (when connection string available).
18-
- OTLP trace + log export (when ``OTEL_EXPORTER_OTLP_ENDPOINT`` set).
16+
- Suppression of noisy Azure Core HTTP logging policy output.
17+
- Trace and log export via ``microsoft-opentelemetry`` distro (auto-detects
18+
Azure Monitor from ``APPLICATIONINSIGHTS_CONNECTION_STRING`` and OTLP
19+
from ``OTEL_EXPORTER_OTLP_ENDPOINT``).
1920
2021
Users may pass a custom callable (or ``None``) via the
2122
``configure_observability`` constructor parameter to override or
@@ -29,7 +30,7 @@
2930
- :func:`set_current_span` / :func:`detach_context` — explicit context management
3031
3132
OpenTelemetry is a required dependency — these functions always create
32-
real spans. Azure Monitor export is optional (lazy-imported).
33+
real spans. Azure Monitor export is optional (auto-configured by the distro).
3334
"""
3435
import logging
3536
import os
@@ -130,16 +131,15 @@ def configure_observability(
130131
setattr(_console, _CONSOLE_HANDLER_ATTR, True)
131132
root.addHandler(_console)
132133

133-
# Suppress noisy Azure SDK and OTel exporter logs
134+
# Suppress the noisy Azure Core HTTP logging policy logger.
134135
logging.getLogger("azure.core.pipeline.policies.http_logging_policy").setLevel(logging.WARNING)
135-
logging.getLogger("azure.monitor.opentelemetry.exporter").setLevel(logging.WARNING)
136136

137137
# Tracing and OTel export
138138
_configure_tracing(connection_string=connection_string)
139139

140140

141141
def _configure_tracing(connection_string: Optional[str] = None) -> None:
142-
"""Configure OpenTelemetry exporters for Azure Monitor and OTLP.
142+
"""Configure OpenTelemetry exporters via the microsoft-opentelemetry distro.
143143
144144
Internal helper called by :func:`configure_observability`.
145145
@@ -148,20 +148,76 @@ def _configure_tracing(connection_string: Optional[str] = None) -> None:
148148
:type connection_string: str or None
149149
"""
150150
resource = _create_resource()
151-
provider = _ensure_trace_provider(resource)
151+
if resource is None:
152+
logger.warning("Failed to create OTel resource — tracing will not be configured.")
153+
return
152154

153-
if provider is not None:
154-
_register_enrichment_processor(provider)
155+
# Build custom processors
156+
agent_name = _config.resolve_agent_name() or None
157+
agent_version = _config.resolve_agent_version() or None
158+
project_id = _config.resolve_project_id() or None
155159

160+
if agent_name and agent_version:
161+
agent_id = f"{agent_name}:{agent_version}"
162+
elif agent_name:
163+
agent_id = agent_name
164+
else:
165+
agent_id = None
166+
167+
span_processors = [
168+
_FoundryEnrichmentSpanProcessor(
169+
agent_name=agent_name, agent_version=agent_version,
170+
agent_id=agent_id, project_id=project_id,
171+
),
172+
]
173+
log_record_processors = [_BaggageLogRecordProcessor()] # type: ignore[list-item]
174+
175+
try:
176+
_setup_distro_export(
177+
resource=resource,
178+
span_processors=span_processors,
179+
log_record_processors=log_record_processors,
180+
connection_string=connection_string,
181+
)
182+
logger.info("Tracing configured successfully via microsoft-opentelemetry distro.")
183+
except ImportError:
184+
logger.warning("microsoft-opentelemetry is not installed — tracing export disabled.")
185+
# Still set up TracerProvider with enrichment processor so spans are created
186+
_ensure_trace_provider(resource, span_processors)
187+
188+
189+
def _setup_distro_export(
190+
*,
191+
resource: Any,
192+
span_processors: list[Any],
193+
log_record_processors: list[Any],
194+
connection_string: Optional[str] = None,
195+
) -> None:
196+
"""Delegate to microsoft-opentelemetry distro for exporter configuration.
197+
198+
Separated into its own function so tests can easily mock it without
199+
intercepting lazy imports.
200+
201+
:keyword resource: OTel resource describing this service.
202+
:keyword span_processors: Span processors to register.
203+
:keyword log_record_processors: Log record processors to register.
204+
:keyword connection_string: Application Insights connection string.
205+
"""
206+
from microsoft.opentelemetry import use_microsoft_opentelemetry
207+
208+
kwargs: dict[str, Any] = {
209+
"resource": resource,
210+
"span_processors": span_processors,
211+
"log_record_processors": log_record_processors,
212+
}
213+
214+
# Azure Monitor export is off by default in the distro — enable it
215+
# when a connection string is available.
156216
if connection_string:
157-
if resource is not None:
158-
_setup_trace_export(provider, connection_string)
159-
_setup_log_export(resource, connection_string)
217+
kwargs["enable_azure_monitor"] = True
218+
kwargs["azure_monitor_connection_string"] = connection_string
160219

161-
otlp_endpoint = _config.resolve_otlp_endpoint()
162-
if otlp_endpoint and resource is not None:
163-
_setup_otlp_trace_export(provider, otlp_endpoint)
164-
_setup_otlp_log_export(resource, otlp_endpoint)
220+
use_microsoft_opentelemetry(**kwargs)
165221

166222

167223
# ======================================================================
@@ -508,7 +564,16 @@ def _create_resource() -> Any:
508564
return Resource.create({_ATTR_SERVICE_NAME: service_name})
509565

510566

511-
def _ensure_trace_provider(resource: Any) -> Any:
567+
def _ensure_trace_provider(resource: Any, span_processors: Optional[list[Any]] = None) -> Any:
568+
"""Get or create a TracerProvider, optionally adding span processors.
569+
570+
Used as a fallback when the microsoft-opentelemetry distro is not installed.
571+
572+
:param resource: OTel resource describing this service.
573+
:type resource: ~typing.Any
574+
:param span_processors: Optional span processors to register.
575+
:type span_processors: list[~typing.Any] or None
576+
"""
512577
if resource is None:
513578
return None
514579
try:
@@ -517,129 +582,15 @@ def _ensure_trace_provider(resource: Any) -> Any:
517582
return None
518583
current = trace.get_tracer_provider()
519584
if hasattr(current, "add_span_processor"):
520-
return current
521-
provider = SdkTracerProvider(resource=resource)
522-
trace.set_tracer_provider(provider)
523-
return provider
524-
525-
526-
_enrichment_configured = False
527-
_az_trace_configured = False
528-
_az_log_configured = False
529-
_otlp_trace_configured = False
530-
_otlp_log_configured = False
531-
532-
533-
def _register_enrichment_processor(provider: Any) -> None:
534-
global _enrichment_configured # pylint: disable=global-statement
535-
if _enrichment_configured:
536-
return
537-
agent_name = _config.resolve_agent_name() or None
538-
agent_version = _config.resolve_agent_version() or None
539-
project_id = _config.resolve_project_id() or None
540-
541-
if agent_name and agent_version:
542-
agent_id = f"{agent_name}:{agent_version}"
543-
elif agent_name:
544-
agent_id = agent_name
585+
provider = current
545586
else:
546-
agent_id = None
547-
548-
provider.add_span_processor(_FoundryEnrichmentSpanProcessor(
549-
agent_name=agent_name, agent_version=agent_version,
550-
agent_id=agent_id, project_id=project_id,
551-
))
552-
_enrichment_configured = True
553-
554-
555-
def _setup_trace_export(provider: Any, connection_string: str) -> None:
556-
global _az_trace_configured # pylint: disable=global-statement
557-
if _az_trace_configured or provider is None:
558-
return
559-
try:
560-
from opentelemetry.sdk.trace.export import BatchSpanProcessor
561-
562-
from azure.monitor.opentelemetry.exporter import AzureMonitorTraceExporter # type: ignore[import-untyped]
563-
except ImportError:
564-
logger.warning("Trace export requires azure-monitor-opentelemetry-exporter.")
565-
return
566-
provider.add_span_processor(BatchSpanProcessor(
567-
AzureMonitorTraceExporter(connection_string=connection_string)))
568-
_az_trace_configured = True
569-
logger.info("Application Insights trace exporter configured.")
570-
571-
572-
def _setup_log_export(resource: Any, connection_string: str) -> None:
573-
global _az_log_configured # pylint: disable=global-statement
574-
if _az_log_configured:
575-
return
576-
try:
577-
from opentelemetry._logs import set_logger_provider
578-
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
579-
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
580-
581-
from azure.monitor.opentelemetry.exporter import AzureMonitorLogExporter # type: ignore[import-untyped]
582-
except ImportError:
583-
logger.warning("Log export requires azure-monitor-opentelemetry-exporter.")
584-
return
585-
log_provider = LoggerProvider(resource=resource)
586-
set_logger_provider(log_provider)
587-
log_provider.add_log_record_processor(BatchLogRecordProcessor(
588-
AzureMonitorLogExporter(connection_string=connection_string)))
589-
log_provider.add_log_record_processor(_BaggageLogRecordProcessor()) # type: ignore[arg-type]
590-
logging.getLogger().addHandler(LoggingHandler(logger_provider=log_provider))
591-
_az_log_configured = True
592-
logger.info("Application Insights log exporter configured.")
593-
594-
595-
def _setup_otlp_trace_export(provider: Any, endpoint: str) -> None:
596-
global _otlp_trace_configured # pylint: disable=global-statement
597-
if _otlp_trace_configured or provider is None:
598-
return
599-
try:
600-
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
601-
from opentelemetry.sdk.trace.export import BatchSpanProcessor
602-
except ImportError:
603-
logger.warning("OTLP trace export requires opentelemetry-exporter-otlp-proto-grpc.")
604-
return
605-
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)))
606-
_otlp_trace_configured = True
607-
logger.info("OTLP trace exporter configured (endpoint=%s).", endpoint)
608-
609-
610-
def _setup_otlp_log_export(resource: Any, endpoint: str) -> None:
611-
global _otlp_log_configured # pylint: disable=global-statement
612-
if _otlp_log_configured:
613-
return
614-
try:
615-
from opentelemetry._logs import get_logger_provider
616-
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
617-
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
618-
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
619-
except ImportError:
620-
logger.warning("OTLP log export requires opentelemetry-exporter-otlp-proto-grpc.")
621-
return
622-
current = get_logger_provider()
623-
if hasattr(current, "add_log_record_processor"):
624-
log_provider = current
625-
else:
626-
from opentelemetry._logs import set_logger_provider
627-
log_provider = LoggerProvider(resource=resource)
628-
set_logger_provider(log_provider)
629-
log_provider.add_log_record_processor( # type: ignore[union-attr]
630-
BatchLogRecordProcessor(OTLPLogExporter(endpoint=endpoint))
631-
)
632-
log_provider.add_log_record_processor( # type: ignore[union-attr]
633-
_BaggageLogRecordProcessor() # type: ignore[arg-type]
634-
)
635-
# Note: LoggingHandler is NOT added here to avoid duplicating the
636-
# handler already installed by _setup_log_export. The OTel LoggerProvider
637-
# receives log records via the handler added there (or from direct OTel
638-
# log API usage). If OTLP is the only exporter, add a handler:
639-
if not _az_log_configured:
640-
logging.getLogger().addHandler(LoggingHandler(logger_provider=log_provider))
641-
_otlp_log_configured = True
642-
logger.info("OTLP log exporter configured (endpoint=%s).", endpoint)
587+
provider = SdkTracerProvider(resource=resource)
588+
trace.set_tracer_provider(provider)
589+
if span_processors and not getattr(provider, "_agentserver_processors_added", False):
590+
for proc in span_processors:
591+
provider.add_span_processor(proc)
592+
provider._agentserver_processors_added = True # type: ignore[attr-defined] # pylint: disable=protected-access
593+
return provider
643594

644595

645596
def _extract_w3c_carrier(headers: Mapping[str, str]) -> dict[str, str]:

sdk/agentserver/azure-ai-agentserver-core/dev_requirements.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
-e ../../../eng/tools/azure-sdk-tools
2-
-e ../../monitor/azure-monitor-opentelemetry-exporter
32
-e ../../monitor/azure-monitor-query
43
-e ../../identity/azure-identity
54
pytest

sdk/agentserver/azure-ai-agentserver-core/pyproject.toml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ dependencies = [
2525
"hypercorn>=0.17.0",
2626
"opentelemetry-api>=1.40.0",
2727
"opentelemetry-sdk>=1.40.0",
28-
"opentelemetry-exporter-otlp-proto-grpc>=1.40.0",
29-
"azure-monitor-opentelemetry-exporter>=1.0.0b49",
28+
"microsoft-opentelemetry>=0.1.0b1",
3029
]
3130

3231
[build-system]
@@ -73,5 +72,4 @@ latestdependency = false
7372
pylint = true
7473
type_check_samples = false
7574

76-
[tool.uv.sources]
77-
azure-monitor-opentelemetry-exporter = { path = "../../monitor/azure-monitor-opentelemetry-exporter" }
75+
[tool.uv.sources]

sdk/agentserver/azure-ai-agentserver-core/tests/test_tracing.py

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,11 @@ def test_observability_always_called(self) -> None:
5353
mock_configure.assert_called_once()
5454

5555
def test_observability_receives_appinsights_env_var(self) -> None:
56-
with mock.patch.dict(os.environ, {"APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=test"}):
56+
with mock.patch.dict(os.environ, {"APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=00000000-0000-0000-0000-000000000000"}):
5757
mock_configure = mock.MagicMock()
5858
AgentServerHost(configure_observability=mock_configure)
5959
mock_configure.assert_called_once()
60-
assert mock_configure.call_args[1]["connection_string"] == "InstrumentationKey=test"
60+
assert mock_configure.call_args[1]["connection_string"] == "InstrumentationKey=00000000-0000-0000-0000-000000000000"
6161

6262
def test_observability_receives_otlp_env_var(self) -> None:
6363
with mock.patch.dict(os.environ, {"OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4318"}):
@@ -78,7 +78,7 @@ def test_observability_receives_constructor_connection_string(self) -> None:
7878

7979
def test_observability_disabled_when_none(self) -> None:
8080
"""Passing configure_observability=None disables all SDK-managed observability."""
81-
with mock.patch.dict(os.environ, {"APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=test"}):
81+
with mock.patch.dict(os.environ, {"APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=00000000-0000-0000-0000-000000000000"}):
8282
# Should not raise even with App Insights configured
8383
AgentServerHost(configure_observability=None)
8484

@@ -117,32 +117,30 @@ def test_explicit_overrides_env_var(self) -> None:
117117

118118

119119
# ------------------------------------------------------------------ #
120-
# _setup_azure_monitor (mocked)
120+
# _setup_distro_export (mocked)
121121
# ------------------------------------------------------------------ #
122122

123123

124-
class TestSetupAzureMonitor:
125-
"""Verify _configure_tracing calls the right exporter setup functions."""
126-
127-
def test_setup_azure_monitor_called_when_conn_str_provided(self) -> None:
128-
with mock.patch("azure.ai.agentserver.core._tracing._setup_trace_export") as mock_trace:
129-
with mock.patch("azure.ai.agentserver.core._tracing._setup_log_export"):
130-
with mock.patch("azure.ai.agentserver.core._tracing._setup_otlp_trace_export"):
131-
with mock.patch("azure.ai.agentserver.core._tracing._setup_otlp_log_export"):
132-
from azure.ai.agentserver.core import _tracing
133-
_tracing._configure_tracing(connection_string="InstrumentationKey=test")
134-
mock_trace.assert_called_once()
135-
args = mock_trace.call_args[0]
136-
assert args[1] == "InstrumentationKey=test"
137-
138-
def test_setup_azure_monitor_not_called_when_no_conn_str(self) -> None:
139-
with mock.patch("azure.ai.agentserver.core._tracing._setup_trace_export") as mock_trace:
140-
with mock.patch("azure.ai.agentserver.core._tracing._setup_log_export"):
141-
with mock.patch("azure.ai.agentserver.core._tracing._setup_otlp_trace_export"):
142-
with mock.patch("azure.ai.agentserver.core._tracing._setup_otlp_log_export"):
143-
from azure.ai.agentserver.core import _tracing
144-
_tracing._configure_tracing(connection_string=None)
145-
mock_trace.assert_not_called()
124+
class TestSetupDistroExport:
125+
"""Verify _configure_tracing calls the distro with the right args."""
126+
127+
def test_distro_called_when_conn_str_provided(self) -> None:
128+
with mock.patch("azure.ai.agentserver.core._tracing._setup_distro_export") as mock_distro:
129+
from azure.ai.agentserver.core import _tracing
130+
_tracing._configure_tracing(connection_string="InstrumentationKey=00000000-0000-0000-0000-000000000000")
131+
mock_distro.assert_called_once()
132+
kwargs = mock_distro.call_args[1]
133+
assert kwargs["connection_string"] == "InstrumentationKey=00000000-0000-0000-0000-000000000000"
134+
assert len(kwargs["span_processors"]) >= 1
135+
assert len(kwargs["log_record_processors"]) >= 1
136+
137+
def test_distro_called_without_conn_str(self) -> None:
138+
with mock.patch("azure.ai.agentserver.core._tracing._setup_distro_export") as mock_distro:
139+
from azure.ai.agentserver.core import _tracing
140+
_tracing._configure_tracing(connection_string=None)
141+
mock_distro.assert_called_once()
142+
kwargs = mock_distro.call_args[1]
143+
assert kwargs["connection_string"] is None
146144

147145

148146
# ------------------------------------------------------------------ #

0 commit comments

Comments
 (0)