From ad22d588bd849bc28a9c716a4c5344690a0ef2b6 Mon Sep 17 00:00:00 2001 From: Ankit Singhal Date: Fri, 1 May 2026 10:17:28 -0700 Subject: [PATCH] Add Foundry-hosted agent ID override for A365 exporter When running in a Foundry-hosted environment (FOUNDRY_HOSTING_ENVIRONMENT=1), override gen_ai.agent.id on spans sent to the A365 exporter with the value from FOUNDRY_AGENT_IDENTITY. This override only affects the A365 export path and does not impact other exporters (Azure Monitor, OTLP, Console). The logic is added to _EnrichingBatchSpanProcessor which exclusively feeds the A365 exporter. The env check happens at processor init time for performance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/microsoft/opentelemetry/a365/constants.py | 4 + .../exporters/enriching_span_processor.py | 20 +++++ tests/a365/test_enriching_span_processor.py | 90 +++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/src/microsoft/opentelemetry/a365/constants.py b/src/microsoft/opentelemetry/a365/constants.py index f02c03f5..6abd2ae9 100644 --- a/src/microsoft/opentelemetry/a365/constants.py +++ b/src/microsoft/opentelemetry/a365/constants.py @@ -30,6 +30,10 @@ ENABLE_OBSERVABILITY = "ENABLE_OBSERVABILITY" ENABLE_A365_OBSERVABILITY = "ENABLE_A365_OBSERVABILITY" +# --- Foundry hosting environment --- +FOUNDRY_HOSTING_ENVIRONMENT_ENV = "FOUNDRY_HOSTING_ENVIRONMENT" +FOUNDRY_AGENT_IDENTITY_ENV = "FOUNDRY_AGENT_IDENTITY" + # --- GenAI semantic conventions --- GEN_AI_CLIENT_OPERATION_DURATION_METRIC_NAME = "gen_ai.client.operation.duration" GEN_AI_CLIENT_TOKEN_USAGE_METRIC_NAME = "gen_ai.client.token.usage" diff --git a/src/microsoft/opentelemetry/a365/core/exporters/enriching_span_processor.py b/src/microsoft/opentelemetry/a365/core/exporters/enriching_span_processor.py index 5abc91fc..4548f429 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/enriching_span_processor.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/enriching_span_processor.py @@ -12,6 +12,7 @@ from __future__ import annotations import logging +import os import threading from collections.abc import Callable from typing import Optional @@ -20,6 +21,9 @@ from opentelemetry.sdk.trace.export import BatchSpanProcessor from microsoft.opentelemetry.a365.constants import ( + FOUNDRY_AGENT_IDENTITY_ENV, + FOUNDRY_HOSTING_ENVIRONMENT_ENV, + GEN_AI_AGENT_ID_KEY, GEN_AI_INPUT_MESSAGES_KEY, GEN_AI_OPERATION_NAME_KEY, INVOKE_AGENT_OPERATION_NAME, @@ -79,6 +83,15 @@ def __init__( super().__init__(*args, **kwargs) # type: ignore[arg-type] self._suppress_invoke_agent_input = suppress_invoke_agent_input + # Foundry-hosted agent ID override: only active when running in + # Foundry hosting environment and identity env var is set. + foundry_env = os.environ.get(FOUNDRY_HOSTING_ENVIRONMENT_ENV, "").strip() + agent_identity = os.environ.get(FOUNDRY_AGENT_IDENTITY_ENV, "").strip() + if foundry_env == "1" and agent_identity: + self._foundry_agent_id: Optional[str] = agent_identity + else: + self._foundry_agent_id = None + def on_end(self, span: ReadableSpan) -> None: """Apply the span enricher and pass to parent for batching.""" enriched_span = span @@ -107,4 +120,11 @@ def on_end(self, span: ReadableSpan) -> None: excluded_attribute_keys={GEN_AI_INPUT_MESSAGES_KEY}, ) + # Override gen_ai.agent.id when running in Foundry-hosted environment + if self._foundry_agent_id is not None: + enriched_span = EnrichedReadableSpan( + enriched_span, + extra_attributes={GEN_AI_AGENT_ID_KEY: self._foundry_agent_id}, + ) + super().on_end(enriched_span) diff --git a/tests/a365/test_enriching_span_processor.py b/tests/a365/test_enriching_span_processor.py index b6827e6b..48e3c770 100644 --- a/tests/a365/test_enriching_span_processor.py +++ b/tests/a365/test_enriching_span_processor.py @@ -74,6 +74,7 @@ def my_enricher(span): processor = _EnrichingBatchSpanProcessor.__new__(_EnrichingBatchSpanProcessor) processor._suppress_invoke_agent_input = False + processor._foundry_agent_id = None original_span = MagicMock(spec=ReadableSpan) original_span.name = "original" @@ -92,6 +93,7 @@ def bad_enricher(span): processor = _EnrichingBatchSpanProcessor.__new__(_EnrichingBatchSpanProcessor) processor._suppress_invoke_agent_input = False + processor._foundry_agent_id = None original_span = MagicMock(spec=ReadableSpan) original_span.name = "original" @@ -105,6 +107,7 @@ def bad_enricher(span): def test_suppress_invoke_agent_input(self): processor = _EnrichingBatchSpanProcessor.__new__(_EnrichingBatchSpanProcessor) processor._suppress_invoke_agent_input = True + processor._foundry_agent_id = None span = MagicMock(spec=ReadableSpan) span.name = "invoke_agent Travel_Assistant" @@ -122,6 +125,7 @@ def test_suppress_invoke_agent_input(self): def test_no_suppress_for_non_invoke_agent(self): processor = _EnrichingBatchSpanProcessor.__new__(_EnrichingBatchSpanProcessor) processor._suppress_invoke_agent_input = True + processor._foundry_agent_id = None span = MagicMock(spec=ReadableSpan) span.name = "chat gpt-4" @@ -135,5 +139,91 @@ def test_no_suppress_for_non_invoke_agent(self): mock_super_on_end.assert_called_once_with(span) +class TestFoundryAgentIdOverride(unittest.TestCase): + """Tests for Foundry-hosted agent ID override in _EnrichingBatchSpanProcessor.""" + + def setUp(self): + unregister_span_enricher() + + def tearDown(self): + unregister_span_enricher() + + @patch.object(_EnrichingBatchSpanProcessor, "__init__", lambda self, *a, **kw: None) + def test_override_applied_when_foundry_agent_id_set(self): + processor = _EnrichingBatchSpanProcessor.__new__(_EnrichingBatchSpanProcessor) + processor._suppress_invoke_agent_input = False + processor._foundry_agent_id = "foundry-agent-123" + + span = MagicMock(spec=ReadableSpan) + span.name = "chat gpt-4" + span.attributes = {"gen_ai.agent.id": "original-id"} + + with patch.object(_EnrichingBatchSpanProcessor.__bases__[0], "on_end") as mock_super_on_end: + processor.on_end(span) + passed_span = mock_super_on_end.call_args[0][0] + self.assertEqual(passed_span.attributes["gen_ai.agent.id"], "foundry-agent-123") + + @patch.object(_EnrichingBatchSpanProcessor, "__init__", lambda self, *a, **kw: None) + def test_override_not_applied_when_foundry_agent_id_none(self): + processor = _EnrichingBatchSpanProcessor.__new__(_EnrichingBatchSpanProcessor) + processor._suppress_invoke_agent_input = False + processor._foundry_agent_id = None + + span = MagicMock(spec=ReadableSpan) + span.name = "chat gpt-4" + span.attributes = {"gen_ai.agent.id": "original-id"} + + with patch.object(_EnrichingBatchSpanProcessor.__bases__[0], "on_end") as mock_super_on_end: + processor.on_end(span) + mock_super_on_end.assert_called_once_with(span) + + @patch.dict("os.environ", {"FOUNDRY_HOSTING_ENVIRONMENT": "1", "FOUNDRY_AGENT_IDENTITY": "env-agent-456"}) + def test_init_reads_env_vars_when_foundry_hosted(self): + with patch.object(_EnrichingBatchSpanProcessor.__bases__[0], "__init__", return_value=None): + processor = _EnrichingBatchSpanProcessor(MagicMock()) + self.assertEqual(processor._foundry_agent_id, "env-agent-456") + + @patch.dict("os.environ", {"FOUNDRY_HOSTING_ENVIRONMENT": "0", "FOUNDRY_AGENT_IDENTITY": "env-agent-456"}) + def test_init_no_override_when_not_foundry_hosted(self): + with patch.object(_EnrichingBatchSpanProcessor.__bases__[0], "__init__", return_value=None): + processor = _EnrichingBatchSpanProcessor(MagicMock()) + self.assertIsNone(processor._foundry_agent_id) + + @patch.dict("os.environ", {"FOUNDRY_HOSTING_ENVIRONMENT": "1"}, clear=False) + def test_init_no_override_when_agent_identity_missing(self): + # Ensure FOUNDRY_AGENT_IDENTITY is not set + import os + + os.environ.pop("FOUNDRY_AGENT_IDENTITY", None) + with patch.object(_EnrichingBatchSpanProcessor.__bases__[0], "__init__", return_value=None): + processor = _EnrichingBatchSpanProcessor(MagicMock()) + self.assertIsNone(processor._foundry_agent_id) + + @patch.dict("os.environ", {}, clear=False) + def test_init_no_override_when_both_env_vars_missing(self): + import os + + os.environ.pop("FOUNDRY_HOSTING_ENVIRONMENT", None) + os.environ.pop("FOUNDRY_AGENT_IDENTITY", None) + with patch.object(_EnrichingBatchSpanProcessor.__bases__[0], "__init__", return_value=None): + processor = _EnrichingBatchSpanProcessor(MagicMock()) + self.assertIsNone(processor._foundry_agent_id) + + @patch.object(_EnrichingBatchSpanProcessor, "__init__", lambda self, *a, **kw: None) + def test_override_sets_agent_id_when_span_has_no_existing_id(self): + processor = _EnrichingBatchSpanProcessor.__new__(_EnrichingBatchSpanProcessor) + processor._suppress_invoke_agent_input = False + processor._foundry_agent_id = "foundry-agent-789" + + span = MagicMock(spec=ReadableSpan) + span.name = "chat gpt-4" + span.attributes = {"gen_ai.operation.name": "chat"} + + with patch.object(_EnrichingBatchSpanProcessor.__bases__[0], "on_end") as mock_super_on_end: + processor.on_end(span) + passed_span = mock_super_on_end.call_args[0][0] + self.assertEqual(passed_span.attributes["gen_ai.agent.id"], "foundry-agent-789") + + if __name__ == "__main__": unittest.main()