Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/microsoft/opentelemetry/a365/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from __future__ import annotations

import logging
import os
import threading
from collections.abc import Callable
from typing import Optional
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Comment on lines +88 to +90
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FOUNDRY_HOSTING_ENVIRONMENT_ENV is being treated as truthy only when the env var equals the literal string "1". Elsewhere in this repo, env-bool parsing normalizes with strip().lower() and accepts ("true", "1", "yes", "on"). To avoid surprising behavior (e.g., "True"/"on" not enabling the override) and to stay consistent with existing env parsing, normalize + use the same truthy set (or reuse an existing env-bool helper) before deciding whether to apply the override.

Suggested change
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:
foundry_env = os.environ.get(FOUNDRY_HOSTING_ENVIRONMENT_ENV, "").strip().lower()
agent_identity = os.environ.get(FOUNDRY_AGENT_IDENTITY_ENV, "").strip()
truthy_env_values = ("true", "1", "yes", "on")
if foundry_env in truthy_env_values and agent_identity:

Copilot uses AI. Check for mistakes.
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
Expand Down Expand Up @@ -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)
90 changes: 90 additions & 0 deletions tests/a365/test_enriching_span_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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")

Comment on lines +180 to +185
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests hardcode Foundry env var names ("FOUNDRY_HOSTING_ENVIRONMENT" / "FOUNDRY_AGENT_IDENTITY") and span attribute keys ("gen_ai.agent.id"). Since constants for these values exist in the codebase now, using the constants here would prevent test drift if names change and keep tests aligned with production code.

Copilot uses AI. Check for mistakes.
@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()
Loading