From 37f784f04597cb1026915de52f197f10cc46c8c8 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 15:16:59 -0600 Subject: [PATCH 01/17] feat: add explicit custom baggage APIs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a365/core/middleware/baggage_builder.py | 40 ++++++++++++- tests/a365/test_baggage_builder.py | 56 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 tests/a365/test_baggage_builder.py diff --git a/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py b/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py index 2b96c586..acd8ca81 100644 --- a/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py +++ b/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py @@ -4,6 +4,7 @@ # Per request baggage builder for OpenTelemetry context propagation. import logging +from collections.abc import Iterable, Mapping from typing import Any from opentelemetry import baggage, context @@ -37,6 +38,8 @@ logger = logging.getLogger(__name__) +_CUSTOM_KEYS_BAGGAGE_KEY = "_internal.custom_keys" + class BaggageBuilder: """Per request baggage builder. @@ -60,6 +63,7 @@ class BaggageBuilder: def __init__(self): """Initialize the baggage builder.""" self._pairs: dict[str, str] = {} + self._custom_keys: list[str] = [] def operation_source(self, value: str | None) -> "BaggageBuilder": """Set the operation source baggage value. @@ -238,13 +242,36 @@ def set_pairs(self, pairs: Any) -> "BaggageBuilder": self._set(str(k), str(v)) return self + def custom_attribute(self, key: str, value: str | None) -> "BaggageBuilder": + """Set a custom baggage value that should propagate to GenAI spans.""" + normalized_key = self._validate_custom_key(key) + if value is not None and value.strip(): + self._pairs[normalized_key] = value + if normalized_key not in self._custom_keys: + self._custom_keys.append(normalized_key) + return self + + def custom_attributes( + self, + attributes: Mapping[str, str | None] | Iterable[tuple[str, str | None]], + ) -> "BaggageBuilder": + """Set custom baggage values that should propagate to GenAI spans.""" + iterator = attributes.items() if isinstance(attributes, Mapping) else attributes + for key, value in iterator: + self.custom_attribute(key, value) + return self + def build(self) -> "BaggageScope": """Apply the collected baggage to the current context. Returns: A context manager that restores the previous baggage on exit """ - return BaggageScope(self._pairs) + pairs = self._pairs.copy() + custom_keys = [key for key in self._custom_keys if pairs.get(key, "").strip()] + if custom_keys: + pairs[_CUSTOM_KEYS_BAGGAGE_KEY] = ",".join(custom_keys) + return BaggageScope(pairs) def _set(self, key: str, value: str | None) -> None: """Add a baggage key/value if the value is not None or whitespace. @@ -256,6 +283,17 @@ def _set(self, key: str, value: str | None) -> None: if value is not None and value.strip(): self._pairs[key] = value + @staticmethod + def _validate_custom_key(key: str) -> str: + normalized_key = key.strip() + if not normalized_key: + raise ValueError("custom baggage key must not be blank") + if "," in normalized_key: + raise ValueError("custom baggage key must not contain commas") + if normalized_key == _CUSTOM_KEYS_BAGGAGE_KEY: + raise ValueError(f"{_CUSTOM_KEYS_BAGGAGE_KEY} is reserved") + return normalized_key + class BaggageScope: """Context manager for baggage scope. diff --git a/tests/a365/test_baggage_builder.py b/tests/a365/test_baggage_builder.py new file mode 100644 index 00000000..98039218 --- /dev/null +++ b/tests/a365/test_baggage_builder.py @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import pytest +from opentelemetry import baggage, context + +from microsoft.opentelemetry.a365.core.middleware.baggage_builder import BaggageBuilder + + +def test_custom_attribute_sets_value_and_metadata(): + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + assert baggage.get_baggage("customer.tier") == "gold" + assert baggage.get_baggage("_internal.custom_keys") == "customer.tier" + + +def test_custom_attributes_track_multiple_keys_in_order_without_duplicates(): + attributes = [ + ("customer.tier", "gold"), + ("customer.region", "west"), + ("customer.tier", "platinum"), + ] + + with BaggageBuilder().custom_attributes(attributes).build(): + assert baggage.get_baggage("customer.tier") == "platinum" + assert baggage.get_baggage("customer.region") == "west" + assert baggage.get_baggage("_internal.custom_keys") == "customer.tier,customer.region" + + +def test_blank_custom_values_are_skipped_without_metadata(): + builder = BaggageBuilder().custom_attribute("customer.tier", " ") + + with builder.build(): + assert baggage.get_baggage("customer.tier") is None + assert baggage.get_baggage("_internal.custom_keys") is None + + +@pytest.mark.parametrize("key", ["", "bad,key", "_internal.custom_keys"]) +def test_custom_attribute_rejects_invalid_keys(key): + with pytest.raises(ValueError): + BaggageBuilder().custom_attribute(key, "value") + + +def test_set_pairs_does_not_mark_custom_metadata(): + with BaggageBuilder().set_pairs({"customer.tier": "gold"}).build(): + assert baggage.get_baggage("customer.tier") == "gold" + assert baggage.get_baggage("_internal.custom_keys") is None + + +def test_baggage_scope_restores_previous_context(): + token = context.attach(baggage.set_baggage("customer.tier", "silver")) + try: + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + assert baggage.get_baggage("customer.tier") == "gold" + assert baggage.get_baggage("customer.tier") == "silver" + finally: + context.detach(token) From d60ca42a62caf0ad6953d1593abfbc1e2766ed50 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 15:18:00 -0600 Subject: [PATCH 02/17] feat: propagate opted-in custom baggage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a365/core/exporters/span_processor.py | 31 ++++++++++ .../a365/core/exporters/utils.py | 2 +- .../a365/core/middleware/baggage_builder.py | 2 +- tests/a365/test_exporter.py | 24 ++++---- tests/a365/test_span_processor.py | 58 +++++++++++++++++++ 5 files changed, 105 insertions(+), 12 deletions(-) diff --git a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py index fef0d4e0..3ef1261a 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py @@ -54,6 +54,8 @@ USER_ID_KEY, USER_NAME_KEY, ) +from microsoft.opentelemetry.a365.core.exporters.utils import GEN_AI_OPERATION_NAMES +from microsoft.opentelemetry.a365.core.middleware.baggage_builder import _CUSTOM_KEYS_BAGGAGE_KEY # mypy: disable-error-code="no-untyped-def" @@ -98,6 +100,30 @@ ] +def _is_genai_span(span, existing) -> bool: + operation_name = existing.get(GEN_AI_OPERATION_NAME_KEY) + if operation_name in GEN_AI_OPERATION_NAMES: + return True + + span_name = getattr(span, "name", None) + return isinstance(span_name, str) and any(span_name.startswith(name) for name in GEN_AI_OPERATION_NAMES) + + +def _custom_baggage_keys(baggage_map) -> list[str]: + metadata = baggage_map.get(_CUSTOM_KEYS_BAGGAGE_KEY) + if not metadata: + return [] + + keys: list[str] = [] + for raw_key in str(metadata).split(","): + key = raw_key.strip() + if not key or key == _CUSTOM_KEYS_BAGGAGE_KEY: + continue + if key not in keys: + keys.append(key) + return keys + + # pylint: disable=broad-exception-caught, too-many-branches, useless-parent-delegation # pylint: disable=global-statement class A365SpanProcessor(BaseSpanProcessor): @@ -151,6 +177,7 @@ def on_start(self, span, parent_context=None): # type: ignore[override] except Exception: baggage_map = {} + is_genai_span = _is_genai_span(span, existing) operation_name = existing.get(GEN_AI_OPERATION_NAME_KEY) is_invoke_agent = False if operation_name == INVOKE_AGENT_OPERATION_NAME: @@ -163,6 +190,10 @@ def on_start(self, span, parent_context=None): # type: ignore[override] for k in INVOKE_AGENT_ATTRIBUTES: if k not in target_keys: target_keys.append(k) + if is_genai_span: + for k in _custom_baggage_keys(baggage_map): + if k not in target_keys: + target_keys.append(k) for key in target_keys: if key in existing: diff --git a/src/microsoft/opentelemetry/a365/core/exporters/utils.py b/src/microsoft/opentelemetry/a365/core/exporters/utils.py index 3cea00bf..4bd2948c 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/utils.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/utils.py @@ -70,7 +70,7 @@ OUTPUT_MESSAGES_OPERATION_NAME, CHAT_OPERATION_NAME, APPLY_GUARDRAIL_OPERATION_NAME, - InferenceOperationType.CHAT.value, + *(operation_type.value for operation_type in InferenceOperationType), } ) diff --git a/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py b/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py index acd8ca81..b6110501 100644 --- a/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py +++ b/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py @@ -60,7 +60,7 @@ class BaggageBuilder: # Baggage is restored after exiting the context """ - def __init__(self): + def __init__(self) -> None: """Initialize the baggage builder.""" self._pairs: dict[str, str] = {} self._custom_keys: list[str] = [] diff --git a/tests/a365/test_exporter.py b/tests/a365/test_exporter.py index 18f2f913..cf94cc83 100644 --- a/tests/a365/test_exporter.py +++ b/tests/a365/test_exporter.py @@ -22,6 +22,7 @@ IdentityKey, ) from microsoft.opentelemetry.a365.core.exporters.persistent_storage import DurableRecord +from microsoft.opentelemetry.a365.core.inference_operation_type import InferenceOperationType def _make_span( @@ -604,21 +605,24 @@ def test_export_includes_inference_operation_type_chat_spans(self): exporter.shutdown() @patch.dict(os.environ, {}, clear=True) - def test_export_filters_out_unsupported_inference_operation_types(self): - """Spans with TextCompletion / GenerateContent are filtered out.""" + def test_export_includes_all_inference_operation_type_spans(self): + """Spans with every InferenceOperationType value are kept.""" exporter = make_exporter() exporter._post_once = MagicMock(return_value=_delivered()) - text_completion_span = _make_span( - name="text_completion_span", trace_id=3, span_id=4, operation_name="TextCompletion" - ) - generate_content_span = _make_span( - name="generate_content_span", trace_id=5, span_id=6, operation_name="GenerateContent" - ) + spans = [ + _make_span( + name=f"{operation_type.value}_span", + trace_id=index + 3, + span_id=index + 4, + operation_name=operation_type.value, + ) + for index, operation_type in enumerate(InferenceOperationType) + ] - result = exporter.export([text_completion_span, generate_content_span]) + result = exporter.export(spans) self.assertEqual(result, SpanExportResult.SUCCESS) - exporter._post_once.assert_not_called() + exporter._post_once.assert_called_once() exporter.shutdown() @patch.dict(os.environ, {}, clear=True) diff --git a/tests/a365/test_span_processor.py b/tests/a365/test_span_processor.py index e9c21aa9..47652932 100644 --- a/tests/a365/test_span_processor.py +++ b/tests/a365/test_span_processor.py @@ -7,11 +7,14 @@ from opentelemetry import baggage, context +from microsoft.opentelemetry.a365.core.exporters.utils import GEN_AI_OPERATION_NAMES from microsoft.opentelemetry.a365.core.exporters.span_processor import ( A365SpanProcessor, COMMON_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES, ) +from microsoft.opentelemetry.a365.core.inference_operation_type import InferenceOperationType +from microsoft.opentelemetry.a365.core.middleware.baggage_builder import BaggageBuilder class TestA365SpanProcessor(unittest.TestCase): @@ -129,6 +132,61 @@ def test_invoke_agent_attributes_not_propagated_for_other_spans(self): for call in span.set_attribute.call_args_list: self.assertNotEqual(call[0][0], "microsoft.a365.caller.agent.id") + def test_custom_baggage_attribute_propagated_to_genai_span(self): + processor = A365SpanProcessor() + + span = MagicMock() + span.name = "invoke_agent Travel_Assistant" + span.attributes = {"gen_ai.operation.name": "invoke_agent"} + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + span.set_attribute.assert_any_call("customer.tier", "gold") + + def test_set_pairs_custom_baggage_is_not_propagated_without_opt_in(self): + processor = A365SpanProcessor() + + span = MagicMock() + span.name = "invoke_agent Travel_Assistant" + span.attributes = {"gen_ai.operation.name": "invoke_agent"} + + with BaggageBuilder().set_pairs({"customer.tier": "gold"}).build(): + processor.on_start(span, parent_context=context.get_current()) + + for call in span.set_attribute.call_args_list: + self.assertNotEqual(call[0][0], "customer.tier") + + def test_custom_baggage_attribute_does_not_overwrite_span_attribute(self): + processor = A365SpanProcessor() + + span = MagicMock() + span.name = "invoke_agent Travel_Assistant" + span.attributes = {"gen_ai.operation.name": "invoke_agent", "customer.tier": "direct"} + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + for call in span.set_attribute.call_args_list: + self.assertNotEqual(call[0][0], "customer.tier") + + def test_custom_baggage_attribute_ignored_on_non_genai_span(self): + processor = A365SpanProcessor() + + span = MagicMock() + span.name = "http request" + span.attributes = {"gen_ai.operation.name": "not_genai"} + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + for call in span.set_attribute.call_args_list: + self.assertNotEqual(call[0][0], "customer.tier") + + def test_genai_operation_names_include_all_inference_operation_values(self): + for operation_type in InferenceOperationType: + self.assertIn(operation_type.value, GEN_AI_OPERATION_NAMES) + def test_empty_baggage(self): processor = A365SpanProcessor() From 2717bebe21e154d5d49984e551baa14a94058a80 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 15:20:36 -0600 Subject: [PATCH 03/17] docs: describe custom baggage propagation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- A365_DOCUMENTATION.md | 20 ++++++++++++++++++++ CHANGELOG.md | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 17aa49ac..4a11c0c9 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -254,6 +254,26 @@ with ( ... ``` +To copy an application-specific baggage value onto Agent365 GenAI spans, +explicitly opt in each key with `custom_attribute()` or `custom_attributes()`: + +```python +with ( + BaggageBuilder() + .tenant_id("contoso-tenant") + .agent_id("weather-agent-001") + .custom_attribute("customer.tier", "gold") + .custom_attributes({"customer.region": "west"}) + .build() +): + with InvokeAgentScope.start(...) as scope: + ... +``` + +`set_pairs()` only sets baggage. It does not opt arbitrary baggage keys into +span attributes; use `custom_attribute()` for any custom key that should appear +on GenAI spans. + ### From TurnContext (Hosting Framework) ```python diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da53e9e..9c9c6369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,10 @@ # Release History +# Unreleased +### Features Added +- Add explicit `BaggageBuilder.custom_attribute()` and `custom_attributes()` + APIs for opting application-specific baggage keys into Agent365 GenAI span + attributes. + # 1.3.9 (2026-09-09) ### Features Added - Update OpenTelemetry dependencies to latest versions, bump `langchain-core` minimum version to address S360, and support the new `httpx2` entry point exposed by `opentelemetry-instrumentation-httpx`. From ed652fa02033beccd0bde1e5cc553bac841acd6c Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 15:27:42 -0600 Subject: [PATCH 04/17] fix: keep custom baggage gate local Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a365/core/exporters/span_processor.py | 28 +++++++++++++++++-- .../a365/core/exporters/utils.py | 2 +- tests/a365/test_span_processor.py | 28 +++++++++++++++---- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py index 3ef1261a..f03ad32c 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py @@ -43,6 +43,10 @@ GEN_AI_CONVERSATION_ID_KEY, GEN_AI_CONVERSATION_ITEM_LINK_KEY, GEN_AI_OPERATION_NAME_KEY, + APPLY_GUARDRAIL_OPERATION_NAME, + CHAT_OPERATION_NAME, + EXECUTE_TOOL_OPERATION_NAME, + OUTPUT_MESSAGES_OPERATION_NAME, INVOKE_AGENT_OPERATION_NAME, SERVER_ADDRESS_KEY, SERVER_PORT_KEY, @@ -54,7 +58,7 @@ USER_ID_KEY, USER_NAME_KEY, ) -from microsoft.opentelemetry.a365.core.exporters.utils import GEN_AI_OPERATION_NAMES +from microsoft.opentelemetry.a365.core.inference_operation_type import InferenceOperationType from microsoft.opentelemetry.a365.core.middleware.baggage_builder import _CUSTOM_KEYS_BAGGAGE_KEY # mypy: disable-error-code="no-untyped-def" @@ -100,13 +104,31 @@ ] +# Recognized GenAI operation names for the custom-baggage gate only. +# This is intentionally broader than the export filter allowlist so custom +# baggage keeps flowing for inference spans even if export filtering remains +# unchanged. +_CUSTOM_BAGGAGE_GENAI_OPERATION_NAMES: frozenset[str] = frozenset( + { + INVOKE_AGENT_OPERATION_NAME, + EXECUTE_TOOL_OPERATION_NAME, + OUTPUT_MESSAGES_OPERATION_NAME, + CHAT_OPERATION_NAME, + APPLY_GUARDRAIL_OPERATION_NAME, + *(operation_type.value for operation_type in InferenceOperationType), + } +) + + def _is_genai_span(span, existing) -> bool: operation_name = existing.get(GEN_AI_OPERATION_NAME_KEY) - if operation_name in GEN_AI_OPERATION_NAMES: + if operation_name in _CUSTOM_BAGGAGE_GENAI_OPERATION_NAMES: return True span_name = getattr(span, "name", None) - return isinstance(span_name, str) and any(span_name.startswith(name) for name in GEN_AI_OPERATION_NAMES) + return isinstance(span_name, str) and any( + span_name.startswith(name) for name in _CUSTOM_BAGGAGE_GENAI_OPERATION_NAMES + ) def _custom_baggage_keys(baggage_map) -> list[str]: diff --git a/src/microsoft/opentelemetry/a365/core/exporters/utils.py b/src/microsoft/opentelemetry/a365/core/exporters/utils.py index 4bd2948c..3cea00bf 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/utils.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/utils.py @@ -70,7 +70,7 @@ OUTPUT_MESSAGES_OPERATION_NAME, CHAT_OPERATION_NAME, APPLY_GUARDRAIL_OPERATION_NAME, - *(operation_type.value for operation_type in InferenceOperationType), + InferenceOperationType.CHAT.value, } ) diff --git a/tests/a365/test_span_processor.py b/tests/a365/test_span_processor.py index 47652932..29765154 100644 --- a/tests/a365/test_span_processor.py +++ b/tests/a365/test_span_processor.py @@ -7,13 +7,11 @@ from opentelemetry import baggage, context -from microsoft.opentelemetry.a365.core.exporters.utils import GEN_AI_OPERATION_NAMES from microsoft.opentelemetry.a365.core.exporters.span_processor import ( A365SpanProcessor, COMMON_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES, ) -from microsoft.opentelemetry.a365.core.inference_operation_type import InferenceOperationType from microsoft.opentelemetry.a365.core.middleware.baggage_builder import BaggageBuilder @@ -183,9 +181,29 @@ def test_custom_baggage_attribute_ignored_on_non_genai_span(self): for call in span.set_attribute.call_args_list: self.assertNotEqual(call[0][0], "customer.tier") - def test_genai_operation_names_include_all_inference_operation_values(self): - for operation_type in InferenceOperationType: - self.assertIn(operation_type.value, GEN_AI_OPERATION_NAMES) + def test_custom_baggage_attribute_propagated_to_text_completion_span(self): + processor = A365SpanProcessor() + + span = MagicMock() + span.name = "TextCompletion summarize" + span.attributes = {"gen_ai.operation.name": "TextCompletion"} + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + span.set_attribute.assert_any_call("customer.tier", "gold") + + def test_custom_baggage_attribute_propagated_to_generate_content_span(self): + processor = A365SpanProcessor() + + span = MagicMock() + span.name = "GenerateContent image" + span.attributes = {"gen_ai.operation.name": "GenerateContent"} + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + span.set_attribute.assert_any_call("customer.tier", "gold") def test_empty_baggage(self): processor = A365SpanProcessor() From db935b9dcd8840e6eef1af969cb6a02ed8b4814a Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 15:32:24 -0600 Subject: [PATCH 05/17] Restore unsupported inference export test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/a365/test_exporter.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/tests/a365/test_exporter.py b/tests/a365/test_exporter.py index cf94cc83..18f2f913 100644 --- a/tests/a365/test_exporter.py +++ b/tests/a365/test_exporter.py @@ -22,7 +22,6 @@ IdentityKey, ) from microsoft.opentelemetry.a365.core.exporters.persistent_storage import DurableRecord -from microsoft.opentelemetry.a365.core.inference_operation_type import InferenceOperationType def _make_span( @@ -605,24 +604,21 @@ def test_export_includes_inference_operation_type_chat_spans(self): exporter.shutdown() @patch.dict(os.environ, {}, clear=True) - def test_export_includes_all_inference_operation_type_spans(self): - """Spans with every InferenceOperationType value are kept.""" + def test_export_filters_out_unsupported_inference_operation_types(self): + """Spans with TextCompletion / GenerateContent are filtered out.""" exporter = make_exporter() exporter._post_once = MagicMock(return_value=_delivered()) - spans = [ - _make_span( - name=f"{operation_type.value}_span", - trace_id=index + 3, - span_id=index + 4, - operation_name=operation_type.value, - ) - for index, operation_type in enumerate(InferenceOperationType) - ] + text_completion_span = _make_span( + name="text_completion_span", trace_id=3, span_id=4, operation_name="TextCompletion" + ) + generate_content_span = _make_span( + name="generate_content_span", trace_id=5, span_id=6, operation_name="GenerateContent" + ) - result = exporter.export(spans) + result = exporter.export([text_completion_span, generate_content_span]) self.assertEqual(result, SpanExportResult.SUCCESS) - exporter._post_once.assert_called_once() + exporter._post_once.assert_not_called() exporter.shutdown() @patch.dict(os.environ, {}, clear=True) From 20615622b4594618f57c11795d56e951491e395c Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 18:01:00 -0600 Subject: [PATCH 06/17] fix: align custom baggage span classification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- A365_DOCUMENTATION.md | 6 +- CHANGELOG.md | 2 + samples/langchain/validate_traces.py | 4 +- .../sample_maf_agent.py | 2 +- src/microsoft/opentelemetry/a365/README.md | 8 +-- .../opentelemetry/a365/core/constants.py | 16 +++++ .../a365/core/exporters/span_processor.py | 70 ++++++++++--------- .../a365/core/middleware/baggage_builder.py | 9 ++- tests/a365/test_baggage_builder.py | 11 +-- tests/a365/test_span_processor.py | 46 ++++++++++++ 10 files changed, 123 insertions(+), 51 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 4a11c0c9..97426f68 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -272,7 +272,11 @@ with ( `set_pairs()` only sets baggage. It does not opt arbitrary baggage keys into span attributes; use `custom_attribute()` for any custom key that should appear -on GenAI spans. +on recognized Agent365 GenAI spans. Baggage propagation never overwrites +attributes already present on the current span. Baggage-propagated values are +applied when the span starts, so they precede later `record_attributes()` calls +when duplicate-key protection is also present; direct/current span attributes +remain authoritative. ### From TurnContext (Hosting Framework) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c9c6369..42292546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Add explicit `BaggageBuilder.custom_attribute()` and `custom_attributes()` APIs for opting application-specific baggage keys into Agent365 GenAI span attributes. +- Recognize Agent365 and inference operation names when deciding whether + opted-in custom baggage applies to a span. # 1.3.9 (2026-09-09) ### Features Added diff --git a/samples/langchain/validate_traces.py b/samples/langchain/validate_traces.py index 2147717d..254582ee 100644 --- a/samples/langchain/validate_traces.py +++ b/samples/langchain/validate_traces.py @@ -153,9 +153,7 @@ def check(label, condition, detail=""): print(f"\n{'='*60}") print("RESPONSES-API FAKE SPAN CHECKS") print(f"{'='*60}") - responses_spans = [ - s for s in llm_spans if s.attributes.get("gen_ai.response.id") == "resp_abc123" - ] + responses_spans = [s for s in llm_spans if s.attributes.get("gen_ai.response.id") == "resp_abc123"] check("Responses-API fake LLM span found", len(responses_spans) == 1, f"found {len(responses_spans)}") if responses_spans: attrs = responses_spans[0].attributes diff --git a/samples/microsoft_agent_framework/sample_maf_agent.py b/samples/microsoft_agent_framework/sample_maf_agent.py index cfbcd166..171a88d5 100644 --- a/samples/microsoft_agent_framework/sample_maf_agent.py +++ b/samples/microsoft_agent_framework/sample_maf_agent.py @@ -60,4 +60,4 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/src/microsoft/opentelemetry/a365/README.md b/src/microsoft/opentelemetry/a365/README.md index 16cdae73..25b35778 100644 --- a/src/microsoft/opentelemetry/a365/README.md +++ b/src/microsoft/opentelemetry/a365/README.md @@ -20,7 +20,7 @@ Core tracing primitives — scopes, configuration, data models, and internal uti | `__init__.py` | Public API surface. Re-exports scope classes, data models, enums, and related core types. | | `agent_details.py` | `AgentDetails` dataclass — metadata about an AI agent (ID, name, description, blueprint/platform IDs, tenant, version). | | `channel.py` | `Channel` dataclass — channel context (name, link) for agent execution. | -| `constants.py` | Core-level constants for span operations, OTel conventions, feature switches, and error types. | +| `constants.py` | Core-level constants for span operations, GenAI processor operation names, baggage metadata, OTel conventions, feature switches, and error types. | | `execute_tool_scope.py` | `ExecuteToolScope` — tracing scope for AI tool executions. Records tool name, arguments, call ID, type, and endpoint. | | `inference_call_details.py` | `InferenceCallDetails` dataclass — LLM call metadata (model, provider, token counts, finish reasons, endpoint). | | `inference_operation_type.py` | `InferenceOperationType` enum — Chat, TextCompletion, GenerateContent. | @@ -45,7 +45,7 @@ Span export pipeline — processors and exporters for Agent365 and Spectra backe | `agent365_exporter_options.py` | `Agent365ExporterOptions` — configuration for the Agent365 exporter (cluster category, token resolver, endpoint flags, batch settings). | | `enriched_span.py` | `EnrichedReadableSpan` — wrapper allowing extra attributes on immutable `ReadableSpan` objects. | | `enriching_span_processor.py` | Span enrichment support with registration for platform instrumentors (LangChain, Semantic Kernel, OpenAI Agents). `_EnrichingBatchSpanProcessor` applies enrichers before batching. | -| `span_processor.py` | `A365SpanProcessor` — propagates OpenTelemetry baggage entries onto spans as attributes, with special handling for invoke_agent spans. | +| `span_processor.py` | `A365SpanProcessor` — propagates documented OpenTelemetry baggage entries onto spans, keeps invoke_agent-specific handling, and copies only opted-in custom baggage keys onto recognized GenAI spans. | | `spectra_exporter_options.py` | `SpectraExporterOptions` — configuration for OTLP export to a Spectra Collector sidecar (gRPC or HTTP, tuned for Kubernetes). | | `utils.py` | Exporter utilities: hex encoding for trace/span IDs, span size truncation, span partitioning, environment variable handling, payload building helpers. | @@ -53,7 +53,7 @@ Span export pipeline — processors and exporters for Agent365 and Spectra backe | File | Description | |------|-------------| -| `baggage_builder.py` | `BaggageBuilder` — fluent API for setting per-request baggage values (tenant ID, agent ID, caller/user details, session/conversation IDs, channel, endpoints). Provides context manager for baggage scope. | +| `baggage_builder.py` | `BaggageBuilder` — fluent API for setting per-request baggage values (tenant ID, agent ID, caller/user details, session/conversation IDs, channel, endpoints) plus `custom_attribute()` / `custom_attributes()` opt-in for custom GenAI span attributes. Provides context manager for baggage scope. | ### `core/models/` @@ -148,7 +148,7 @@ below are available via `from microsoft.opentelemetry.a365.core import ...`. | Symbol | Kind | Description | |--------|------|-------------| -| `BaggageBuilder` | class | Fluent API for setting per-request baggage (tenant, agent, user, channel, session, conversation). Call `.build()` to get a context manager. | +| `BaggageBuilder` | class | Fluent API for setting per-request baggage (tenant, agent, user, channel, session, conversation) and opting custom baggage keys into recognized GenAI span attributes. Call `.build()` to get a context manager. | #### Data Classes diff --git a/src/microsoft/opentelemetry/a365/core/constants.py b/src/microsoft/opentelemetry/a365/core/constants.py index 6644ed03..ee263a32 100644 --- a/src/microsoft/opentelemetry/a365/core/constants.py +++ b/src/microsoft/opentelemetry/a365/core/constants.py @@ -7,6 +7,8 @@ shared across the Agent365 core scopes and exporters. """ +from microsoft.opentelemetry.a365.core.inference_operation_type import InferenceOperationType + # --- Span operation names --- INVOKE_AGENT_OPERATION_NAME = "invoke_agent" EXECUTE_TOOL_OPERATION_NAME = "execute_tool" @@ -14,6 +16,20 @@ CHAT_OPERATION_NAME = "chat" APPLY_GUARDRAIL_OPERATION_NAME = "apply_guardrail" +GEN_AI_PROCESSOR_OPERATION_NAMES: frozenset[str] = frozenset( + { + INVOKE_AGENT_OPERATION_NAME, + EXECUTE_TOOL_OPERATION_NAME, + OUTPUT_MESSAGES_OPERATION_NAME, + CHAT_OPERATION_NAME, + APPLY_GUARDRAIL_OPERATION_NAME, + } + | {operation.value for operation in InferenceOperationType} +) + +# --- Baggage metadata --- +CUSTOM_KEYS_BAGGAGE_KEY = "_internal.custom_keys" + # --- OpenTelemetry semantic conventions --- ERROR_TYPE_KEY = "error.type" ERROR_MESSAGE_KEY = "error.message" diff --git a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py index f03ad32c..0f9af26c 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py @@ -16,12 +16,16 @@ from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from opentelemetry import baggage, context from opentelemetry.sdk.trace import SpanProcessor as BaseSpanProcessor -from microsoft.opentelemetry.a365.constants import ( +from microsoft.opentelemetry.a365.core.constants import ( CHANNEL_LINK_KEY, CHANNEL_NAME_KEY, + CUSTOM_KEYS_BAGGAGE_KEY, CUSTOM_PARENT_SPAN_ID_KEY, CUSTOM_SPAN_NAME_KEY, GEN_AI_AGENT_AUID_KEY, @@ -43,10 +47,7 @@ GEN_AI_CONVERSATION_ID_KEY, GEN_AI_CONVERSATION_ITEM_LINK_KEY, GEN_AI_OPERATION_NAME_KEY, - APPLY_GUARDRAIL_OPERATION_NAME, - CHAT_OPERATION_NAME, - EXECUTE_TOOL_OPERATION_NAME, - OUTPUT_MESSAGES_OPERATION_NAME, + GEN_AI_PROCESSOR_OPERATION_NAMES, INVOKE_AGENT_OPERATION_NAME, SERVER_ADDRESS_KEY, SERVER_PORT_KEY, @@ -58,8 +59,6 @@ USER_ID_KEY, USER_NAME_KEY, ) -from microsoft.opentelemetry.a365.core.inference_operation_type import InferenceOperationType -from microsoft.opentelemetry.a365.core.middleware.baggage_builder import _CUSTOM_KEYS_BAGGAGE_KEY # mypy: disable-error-code="no-untyped-def" @@ -104,42 +103,49 @@ ] -# Recognized GenAI operation names for the custom-baggage gate only. -# This is intentionally broader than the export filter allowlist so custom -# baggage keeps flowing for inference spans even if export filtering remains -# unchanged. -_CUSTOM_BAGGAGE_GENAI_OPERATION_NAMES: frozenset[str] = frozenset( - { - INVOKE_AGENT_OPERATION_NAME, - EXECUTE_TOOL_OPERATION_NAME, - OUTPUT_MESSAGES_OPERATION_NAME, - CHAT_OPERATION_NAME, - APPLY_GUARDRAIL_OPERATION_NAME, - *(operation_type.value for operation_type in InferenceOperationType), - } -) - +def _recognized_operation_name(value: object | None) -> str | None: + return value if isinstance(value, str) and value in GEN_AI_PROCESSOR_OPERATION_NAMES else None -def _is_genai_span(span, existing) -> bool: - operation_name = existing.get(GEN_AI_OPERATION_NAME_KEY) - if operation_name in _CUSTOM_BAGGAGE_GENAI_OPERATION_NAMES: - return True +def _operation_name_from_span_name(span: Any) -> str | None: span_name = getattr(span, "name", None) - return isinstance(span_name, str) and any( - span_name.startswith(name) for name in _CUSTOM_BAGGAGE_GENAI_OPERATION_NAMES - ) + if not isinstance(span_name, str): + return None + + for operation_name in GEN_AI_PROCESSOR_OPERATION_NAMES: + if span_name == operation_name or span_name.startswith(f"{operation_name} "): + return operation_name + return None + + +def _classify_gen_ai_operation( + span: Any, + existing_attributes: Mapping[str, object], + baggage_map: Mapping[str, object], +) -> str | None: + if GEN_AI_OPERATION_NAME_KEY in existing_attributes: + return _recognized_operation_name(existing_attributes.get(GEN_AI_OPERATION_NAME_KEY)) + + baggage_operation_name = _recognized_operation_name(baggage_map.get(GEN_AI_OPERATION_NAME_KEY)) + if baggage_operation_name is not None: + return baggage_operation_name + + return _operation_name_from_span_name(span) + + +def _is_gen_ai_span(span: Any, existing_attributes: Mapping[str, object], baggage_map: Mapping[str, object]) -> bool: + return _classify_gen_ai_operation(span, existing_attributes, baggage_map) is not None def _custom_baggage_keys(baggage_map) -> list[str]: - metadata = baggage_map.get(_CUSTOM_KEYS_BAGGAGE_KEY) + metadata = baggage_map.get(CUSTOM_KEYS_BAGGAGE_KEY) if not metadata: return [] keys: list[str] = [] for raw_key in str(metadata).split(","): key = raw_key.strip() - if not key or key == _CUSTOM_KEYS_BAGGAGE_KEY: + if not key or key == CUSTOM_KEYS_BAGGAGE_KEY: continue if key not in keys: keys.append(key) @@ -199,8 +205,8 @@ def on_start(self, span, parent_context=None): # type: ignore[override] except Exception: baggage_map = {} - is_genai_span = _is_genai_span(span, existing) operation_name = existing.get(GEN_AI_OPERATION_NAME_KEY) + is_genai_span = _is_gen_ai_span(span, existing, baggage_map) is_invoke_agent = False if operation_name == INVOKE_AGENT_OPERATION_NAME: is_invoke_agent = True diff --git a/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py b/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py index b6110501..caa1d6df 100644 --- a/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py +++ b/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py @@ -12,6 +12,7 @@ from microsoft.opentelemetry.a365.core.constants import ( CHANNEL_LINK_KEY, CHANNEL_NAME_KEY, + CUSTOM_KEYS_BAGGAGE_KEY, GEN_AI_AGENT_AUID_KEY, GEN_AI_AGENT_BLUEPRINT_ID_KEY, GEN_AI_AGENT_DESCRIPTION_KEY, @@ -38,8 +39,6 @@ logger = logging.getLogger(__name__) -_CUSTOM_KEYS_BAGGAGE_KEY = "_internal.custom_keys" - class BaggageBuilder: """Per request baggage builder. @@ -270,7 +269,7 @@ def build(self) -> "BaggageScope": pairs = self._pairs.copy() custom_keys = [key for key in self._custom_keys if pairs.get(key, "").strip()] if custom_keys: - pairs[_CUSTOM_KEYS_BAGGAGE_KEY] = ",".join(custom_keys) + pairs[CUSTOM_KEYS_BAGGAGE_KEY] = ",".join(custom_keys) return BaggageScope(pairs) def _set(self, key: str, value: str | None) -> None: @@ -290,8 +289,8 @@ def _validate_custom_key(key: str) -> str: raise ValueError("custom baggage key must not be blank") if "," in normalized_key: raise ValueError("custom baggage key must not contain commas") - if normalized_key == _CUSTOM_KEYS_BAGGAGE_KEY: - raise ValueError(f"{_CUSTOM_KEYS_BAGGAGE_KEY} is reserved") + if normalized_key == CUSTOM_KEYS_BAGGAGE_KEY: + raise ValueError(f"{CUSTOM_KEYS_BAGGAGE_KEY} is reserved") return normalized_key diff --git a/tests/a365/test_baggage_builder.py b/tests/a365/test_baggage_builder.py index 98039218..eb8db7cf 100644 --- a/tests/a365/test_baggage_builder.py +++ b/tests/a365/test_baggage_builder.py @@ -4,13 +4,14 @@ import pytest from opentelemetry import baggage, context +from microsoft.opentelemetry.a365.core.constants import CUSTOM_KEYS_BAGGAGE_KEY from microsoft.opentelemetry.a365.core.middleware.baggage_builder import BaggageBuilder def test_custom_attribute_sets_value_and_metadata(): with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): assert baggage.get_baggage("customer.tier") == "gold" - assert baggage.get_baggage("_internal.custom_keys") == "customer.tier" + assert baggage.get_baggage(CUSTOM_KEYS_BAGGAGE_KEY) == "customer.tier" def test_custom_attributes_track_multiple_keys_in_order_without_duplicates(): @@ -23,7 +24,7 @@ def test_custom_attributes_track_multiple_keys_in_order_without_duplicates(): with BaggageBuilder().custom_attributes(attributes).build(): assert baggage.get_baggage("customer.tier") == "platinum" assert baggage.get_baggage("customer.region") == "west" - assert baggage.get_baggage("_internal.custom_keys") == "customer.tier,customer.region" + assert baggage.get_baggage(CUSTOM_KEYS_BAGGAGE_KEY) == "customer.tier,customer.region" def test_blank_custom_values_are_skipped_without_metadata(): @@ -31,10 +32,10 @@ def test_blank_custom_values_are_skipped_without_metadata(): with builder.build(): assert baggage.get_baggage("customer.tier") is None - assert baggage.get_baggage("_internal.custom_keys") is None + assert baggage.get_baggage(CUSTOM_KEYS_BAGGAGE_KEY) is None -@pytest.mark.parametrize("key", ["", "bad,key", "_internal.custom_keys"]) +@pytest.mark.parametrize("key", ["", "bad,key", CUSTOM_KEYS_BAGGAGE_KEY]) def test_custom_attribute_rejects_invalid_keys(key): with pytest.raises(ValueError): BaggageBuilder().custom_attribute(key, "value") @@ -43,7 +44,7 @@ def test_custom_attribute_rejects_invalid_keys(key): def test_set_pairs_does_not_mark_custom_metadata(): with BaggageBuilder().set_pairs({"customer.tier": "gold"}).build(): assert baggage.get_baggage("customer.tier") == "gold" - assert baggage.get_baggage("_internal.custom_keys") is None + assert baggage.get_baggage(CUSTOM_KEYS_BAGGAGE_KEY) is None def test_baggage_scope_restores_previous_context(): diff --git a/tests/a365/test_span_processor.py b/tests/a365/test_span_processor.py index 29765154..fd096c2c 100644 --- a/tests/a365/test_span_processor.py +++ b/tests/a365/test_span_processor.py @@ -13,6 +13,7 @@ INVOKE_AGENT_ATTRIBUTES, ) from microsoft.opentelemetry.a365.core.middleware.baggage_builder import BaggageBuilder +from microsoft.opentelemetry.a365.core.constants import GEN_AI_OPERATION_NAME_KEY class TestA365SpanProcessor(unittest.TestCase): @@ -181,6 +182,51 @@ def test_custom_baggage_attribute_ignored_on_non_genai_span(self): for call in span.set_attribute.call_args_list: self.assertNotEqual(call[0][0], "customer.tier") + def test_custom_baggage_attribute_ignored_for_false_operation_prefixes(self): + processor = A365SpanProcessor() + + for span_name in ("chatbot_loop", "execute_toolbox"): + with self.subTest(span_name=span_name): + span = MagicMock() + span.name = span_name + span.attributes = {} + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + for call in span.set_attribute.call_args_list: + self.assertNotEqual(call[0][0], "customer.tier") + + def test_custom_baggage_attribute_uses_recognized_baggage_operation_name(self): + processor = A365SpanProcessor() + + span = MagicMock() + span.name = "http request" + span.attributes = {} + + with ( + BaggageBuilder() + .set_pairs({GEN_AI_OPERATION_NAME_KEY: "chat"}) + .custom_attribute("customer.tier", "gold") + .build() + ): + processor.on_start(span, parent_context=context.get_current()) + + span.set_attribute.assert_any_call("customer.tier", "gold") + + def test_custom_baggage_attribute_honors_unrecognized_explicit_operation_name(self): + processor = A365SpanProcessor() + + span = MagicMock() + span.name = "chat gpt-4" + span.attributes = {GEN_AI_OPERATION_NAME_KEY: "not_genai"} + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + for call in span.set_attribute.call_args_list: + self.assertNotEqual(call[0][0], "customer.tier") + def test_custom_baggage_attribute_propagated_to_text_completion_span(self): processor = A365SpanProcessor() From bad519002255d1333885f8026ba3a28885d75131 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 19:04:43 -0600 Subject: [PATCH 07/17] fix: align custom baggage span classifier Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../opentelemetry/a365/core/constants.py | 41 ++++++++ .../a365/core/exporters/span_processor.py | 96 ++++++++++++++++--- tests/a365/test_span_processor.py | 70 ++++++++++++++ 3 files changed, 195 insertions(+), 12 deletions(-) diff --git a/src/microsoft/opentelemetry/a365/core/constants.py b/src/microsoft/opentelemetry/a365/core/constants.py index ee263a32..51225c3e 100644 --- a/src/microsoft/opentelemetry/a365/core/constants.py +++ b/src/microsoft/opentelemetry/a365/core/constants.py @@ -37,6 +37,47 @@ AZURE_RP_NAMESPACE_VALUE = "Microsoft.CognitiveServices" SOURCE_NAME = "Agent365Sdk" +# --- GenAI instrumentation recognition (span-start signals) --- +# ``gen_ai.operation.name`` is frequently applied *after* a span starts: +# LangChain and the OpenAI Agents processor set it (and rename the span) when +# the run finishes, and Semantic Kernel / Agent Framework call +# ``span.set_attributes`` on the line following ``start_span``. A span +# processor's ``on_start`` hook therefore cannot rely on that attribute alone. +# +# ``ReadWriteSpan.instrumentation_scope`` *is* populated at ``on_start``, so the +# tracer (source) name of a supported GenAI instrumentation is used as an +# additional positive signal. A scope matches when it equals a root exactly or +# is a dotted child of it, which keeps unrelated instrumentations (HTTP, DB, +# web frameworks) and lookalike names such as ``semantic_kernel_helpers`` out. +GEN_AI_INSTRUMENTATION_SCOPE_ROOTS: tuple[str, ...] = ( + # Agent365 SDK scopes (``OpenTelemetryScope``). + SOURCE_NAME, + # Microsoft Agent Framework SDK (``get_tracer("agent_framework")``). + "agent_framework", + # Semantic Kernel SDK (model/agent/function diagnostics use ``__name__``). + "semantic_kernel", + # In-distro LangChain and OpenAI Agents tracers. + "microsoft.opentelemetry._genai", + # Upstream OpenAI instrumentations supported by this distro. + "opentelemetry.instrumentation.openai_v2", + "opentelemetry.instrumentation.openai_agents", +) + +# Span names emitted by supported GenAI instrumentations before they rename the +# span. Semantic Kernel <= 1.37 starts inference spans as +# ``chat.completions `` / ``text.completions `` and >= 1.38 as +# ``text_completions ``; matching is exact or up to a trailing space so +# nearby names such as ``chat.completions.retry`` are not claimed. +GEN_AI_INITIAL_SPAN_NAMES: frozenset[str] = frozenset( + { + "chat.completions", + "chat.streaming_completions", + "text.completions", + "text.streaming_completions", + "text_completions", + } +) + # --- Feature switches --- ENABLE_OPENTELEMETRY_SWITCH = "Azure.Experimental.EnableActivitySource" TRACE_CONTENTS_SWITCH = "Azure.Experimental.TraceGenAIMessageContent" diff --git a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py index 0f9af26c..f8dce620 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py @@ -12,11 +12,28 @@ * For each documented key with a truthy value not already present as a span attribute, add it via span.set_attribute * Never overwrites existing attributes + +Custom baggage is propagated only to recognized GenAI spans. A span is +recognized as GenAI when any of the following signals fires at ``on_start``: + + 1. An explicit ``gen_ai.operation.name`` attribute holding a recognized + operation. An explicit *unrecognized* value is authoritative and + suppresses the two inference signals below. + 2. A recognized ``gen_ai.operation.name`` baggage entry. + 3. A span name that is (or starts with) a recognized operation name. + 4. A span name a supported instrumentation is known to use before it renames + the span (Semantic Kernel ``chat.completions ``). + 5. The instrumentation scope (source) name of a supported GenAI + instrumentation. + +Only signals 1-3 identify *which* operation a span represents, which is what +gates the invoke_agent-only attributes. """ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass from typing import Any from opentelemetry import baggage, context @@ -46,6 +63,8 @@ GEN_AI_CALLER_CLIENT_IP_KEY, GEN_AI_CONVERSATION_ID_KEY, GEN_AI_CONVERSATION_ITEM_LINK_KEY, + GEN_AI_INITIAL_SPAN_NAMES, + GEN_AI_INSTRUMENTATION_SCOPE_ROOTS, GEN_AI_OPERATION_NAME_KEY, GEN_AI_PROCESSOR_OPERATION_NAMES, INVOKE_AGENT_OPERATION_NAME, @@ -103,13 +122,24 @@ ] +@dataclass(frozen=True) +class _GenAISpanClassification: + is_gen_ai_span: bool + operation_name: str | None = None + + def _recognized_operation_name(value: object | None) -> str | None: return value if isinstance(value, str) and value in GEN_AI_PROCESSOR_OPERATION_NAMES else None -def _operation_name_from_span_name(span: Any) -> str | None: +def _span_name(span: Any) -> str | None: span_name = getattr(span, "name", None) - if not isinstance(span_name, str): + return span_name if isinstance(span_name, str) else None + + +def _operation_name_from_span_name(span: Any) -> str | None: + span_name = _span_name(span) + if span_name is None: return None for operation_name in GEN_AI_PROCESSOR_OPERATION_NAMES: @@ -118,11 +148,43 @@ def _operation_name_from_span_name(span: Any) -> str | None: return None +def _has_known_initial_span_name(span: Any) -> bool: + """Match span names supported instrumentations use before renaming the span.""" + span_name = _span_name(span) + if span_name is None: + return False + + return any(span_name == known or span_name.startswith(f"{known} ") for known in GEN_AI_INITIAL_SPAN_NAMES) + + +def _instrumentation_scope_name(span: Any) -> str | None: + """Read the tracer (source) name recorded on a ReadWriteSpan.""" + # pylint: disable=broad-exception-caught + for attribute_name in ("instrumentation_scope", "instrumentation_info"): + try: + scope = getattr(span, attribute_name, None) + scope_name = getattr(scope, "name", None) if scope is not None else None + except Exception: + continue + if isinstance(scope_name, str) and scope_name: + return scope_name + return None + + +def _is_supported_gen_ai_scope(span: Any) -> bool: + scope_name = _instrumentation_scope_name(span) + if scope_name is None: + return False + + return any(scope_name == root or scope_name.startswith(f"{root}.") for root in GEN_AI_INSTRUMENTATION_SCOPE_ROOTS) + + def _classify_gen_ai_operation( span: Any, existing_attributes: Mapping[str, object], baggage_map: Mapping[str, object], ) -> str | None: + """Resolve the GenAI operation a span represents, or ``None`` if unknown.""" if GEN_AI_OPERATION_NAME_KEY in existing_attributes: return _recognized_operation_name(existing_attributes.get(GEN_AI_OPERATION_NAME_KEY)) @@ -133,8 +195,24 @@ def _classify_gen_ai_operation( return _operation_name_from_span_name(span) +def _classify_gen_ai_span( + span: Any, + existing_attributes: Mapping[str, object], + baggage_map: Mapping[str, object], +) -> _GenAISpanClassification: + operation_name = _classify_gen_ai_operation(span, existing_attributes, baggage_map) + if operation_name is not None: + return _GenAISpanClassification(True, operation_name) + + if GEN_AI_OPERATION_NAME_KEY in existing_attributes: + # The span declared an operation this processor does not handle. + return _GenAISpanClassification(False) + + return _GenAISpanClassification(_has_known_initial_span_name(span) or _is_supported_gen_ai_scope(span)) + + def _is_gen_ai_span(span: Any, existing_attributes: Mapping[str, object], baggage_map: Mapping[str, object]) -> bool: - return _classify_gen_ai_operation(span, existing_attributes, baggage_map) is not None + return _classify_gen_ai_span(span, existing_attributes, baggage_map).is_gen_ai_span def _custom_baggage_keys(baggage_map) -> list[str]: @@ -205,20 +283,14 @@ def on_start(self, span, parent_context=None): # type: ignore[override] except Exception: baggage_map = {} - operation_name = existing.get(GEN_AI_OPERATION_NAME_KEY) - is_genai_span = _is_gen_ai_span(span, existing, baggage_map) - is_invoke_agent = False - if operation_name == INVOKE_AGENT_OPERATION_NAME: - is_invoke_agent = True - elif isinstance(getattr(span, "name", None), str) and span.name.startswith(INVOKE_AGENT_OPERATION_NAME): - is_invoke_agent = True + classification = _classify_gen_ai_span(span, existing, baggage_map) target_keys = list(COMMON_ATTRIBUTES) - if is_invoke_agent: + if classification.operation_name == INVOKE_AGENT_OPERATION_NAME: for k in INVOKE_AGENT_ATTRIBUTES: if k not in target_keys: target_keys.append(k) - if is_genai_span: + if classification.is_gen_ai_span: for k in _custom_baggage_keys(baggage_map): if k not in target_keys: target_keys.append(k) diff --git a/tests/a365/test_span_processor.py b/tests/a365/test_span_processor.py index fd096c2c..4871eccb 100644 --- a/tests/a365/test_span_processor.py +++ b/tests/a365/test_span_processor.py @@ -3,6 +3,7 @@ # pylint: disable=no-member import unittest +from types import SimpleNamespace from unittest.mock import MagicMock from opentelemetry import baggage, context @@ -251,6 +252,75 @@ def test_custom_baggage_attribute_propagated_to_generate_content_span(self): span.set_attribute.assert_any_call("customer.tier", "gold") + def test_custom_baggage_attribute_propagated_to_known_initial_span_name(self): + processor = A365SpanProcessor() + + span = MagicMock() + span.name = "chat.completions gpt-4o" + span.attributes = {} + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + span.set_attribute.assert_any_call("customer.tier", "gold") + + def test_custom_baggage_attribute_propagated_to_supported_scope_child(self): + processor = A365SpanProcessor() + + span = MagicMock() + span.name = "ChatOpenAI" + span.attributes = {} + span.instrumentation_scope = SimpleNamespace(name="microsoft.opentelemetry._genai._langchain") + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + span.set_attribute.assert_any_call("customer.tier", "gold") + + def test_custom_baggage_attribute_ignored_for_scope_prefix_without_boundary(self): + processor = A365SpanProcessor() + + span = MagicMock() + span.name = "helper span" + span.attributes = {} + span.instrumentation_scope = SimpleNamespace(name="semantic_kernel_helpers") + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + for call in span.set_attribute.call_args_list: + self.assertNotEqual(call[0][0], "customer.tier") + + def test_invoke_agent_attributes_use_recognized_baggage_operation_name(self): + processor = A365SpanProcessor() + + span = MagicMock() + span.name = "http request" + span.attributes = {} + + ctx = context.get_current() + ctx = baggage.set_baggage(GEN_AI_OPERATION_NAME_KEY, "invoke_agent", ctx) + ctx = baggage.set_baggage("microsoft.a365.caller.agent.id", "caller-1", ctx) + + processor.on_start(span, parent_context=ctx) + + span.set_attribute.assert_any_call("microsoft.a365.caller.agent.id", "caller-1") + + def test_invoke_agent_attributes_ignored_for_raw_prefix_without_boundary(self): + processor = A365SpanProcessor() + + span = MagicMock() + span.name = "invoke_agent.debug" + span.attributes = {} + + ctx = context.get_current() + ctx = baggage.set_baggage("microsoft.a365.caller.agent.id", "caller-1", ctx) + + processor.on_start(span, parent_context=ctx) + + for call in span.set_attribute.call_args_list: + self.assertNotEqual(call[0][0], "microsoft.a365.caller.agent.id") + def test_empty_baggage(self): processor = A365SpanProcessor() From 4b99656c49bf3e840db93e69c7150bd435589783 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 19:34:09 -0600 Subject: [PATCH 08/17] fix: keep custom baggage on unmodeled GenAI operations An explicit but unrecognized gen_ai.operation.name attribute classified a span as non-GenAI before the supported instrumentation-scope signal was evaluated, so opted-in custom baggage never reached real OpenAI Agents, LangChain, Agent Framework and openai_v2 spans whose operation is chain, embeddings, text_completion, generate_content or create_agent. The classifier now evaluates signals in order: a recognized explicit attribute yields GenAI with a known operation; an unrecognized explicit attribute stays authoritative over baggage and span-name inference but still falls through to instrumentation-scope detection; without an explicit attribute, recognized baggage, then span name, then scope apply. Scope-only recognition means GenAI with an unknown operation, so common and custom baggage may apply while invoke_agent-only attributes are withheld. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- A365_DOCUMENTATION.md | 15 ++ CHANGELOG.md | 5 + .../a365/core/exporters/span_processor.py | 76 +++++----- tests/a365/test_span_processor.py | 134 ++++++++++++++++++ 4 files changed, 194 insertions(+), 36 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 97426f68..bb981ad7 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -278,6 +278,21 @@ applied when the span starts, so they precede later `record_attributes()` calls when duplicate-key protection is also present; direct/current span attributes remain authoritative. +A span is recognized as GenAI at span start by evaluating these signals in +order: a supported `gen_ai.operation.name` attribute; if that attribute is +present but unrecognized (`chain`, `embeddings`, `text_completion`, +`generate_content`, `create_agent`, ...) it is authoritative, so baggage and +span-name inference are skipped and only the instrumentation scope can still +classify the span; otherwise a recognized `gen_ai.operation.name` baggage entry, +then a span name matching a supported operation (`invoke_agent ...`, +`chat ...`, ...) or a known pre-rename name (`chat.completions ...`), then a +supported GenAI instrumentation scope (`Agent365Sdk`, `semantic_kernel.*`, +`agent_framework`, `microsoft.opentelemetry._genai.*`, +`opentelemetry.instrumentation.openai_v2`, +`opentelemetry.instrumentation.openai_agents`). Spans classified only by +instrumentation scope are GenAI with an unknown operation: opted-in custom +baggage applies to them, but `invoke_agent`-only attributes never do. + ### From TurnContext (Hosting Framework) ```python diff --git a/CHANGELOG.md b/CHANGELOG.md index 42292546..26a6388a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ attributes. - Recognize Agent365 and inference operation names when deciding whether opted-in custom baggage applies to a span. +- Keep spans that declare an operation the processor does not model (`chain`, + `embeddings`, `text_completion`, `generate_content`, `create_agent`) eligible + for custom baggage when a supported GenAI instrumentation scope emitted them. + Such spans stay operation-unknown, so `invoke_agent`-only attributes are still + withheld. # 1.3.9 (2026-09-09) ### Features Added diff --git a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py index f8dce620..14a2f849 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py @@ -14,20 +14,29 @@ * Never overwrites existing attributes Custom baggage is propagated only to recognized GenAI spans. A span is -recognized as GenAI when any of the following signals fires at ``on_start``: +recognized as GenAI by evaluating these signals in order at ``on_start``: 1. An explicit ``gen_ai.operation.name`` attribute holding a recognized - operation. An explicit *unrecognized* value is authoritative and - suppresses the two inference signals below. - 2. A recognized ``gen_ai.operation.name`` baggage entry. - 3. A span name that is (or starts with) a recognized operation name. - 4. A span name a supported instrumentation is known to use before it renames - the span (Semantic Kernel ``chat.completions ``). + operation: GenAI with a known operation. + 2. An explicit but *unrecognized* ``gen_ai.operation.name`` attribute: the + attribute is authoritative, so the baggage and span-name inference of + signals 3 and 4 is skipped. The span is still GenAI when a supported + instrumentation emitted it (signal 5), with an unknown operation. + 3. A recognized ``gen_ai.operation.name`` baggage entry. + 4. A span name that is (or starts with) a recognized operation name, or a + name a supported instrumentation is known to use before it renames the + span (Semantic Kernel ``chat.completions ``). 5. The instrumentation scope (source) name of a supported GenAI - instrumentation. - -Only signals 1-3 identify *which* operation a span represents, which is what -gates the invoke_agent-only attributes. + instrumentation: GenAI with an unknown operation. + +Signals 4 and 5 exist because most GenAI instrumentations apply +``gen_ai.operation.name`` *after* the span starts: LangChain chat spans start +as ``ChatOpenAI`` and the OpenAI Agents processor starts workflow spans as +``Agent workflow``. Signal 5 also keeps spans whose operation this processor +does not model (``chain``, ``embeddings``, ``text_completion``, +``generate_content``, ``create_agent``) from being dropped. Only signals 1, 3 +and 4 identify *which* operation a span represents, which is what gates the +invoke_agent-only attributes. """ from __future__ import annotations @@ -124,6 +133,8 @@ @dataclass(frozen=True) class _GenAISpanClassification: + """Whether a span is GenAI and, when identifiable, the operation it represents.""" + is_gen_ai_span: bool operation_name: str | None = None @@ -179,42 +190,35 @@ def _is_supported_gen_ai_scope(span: Any) -> bool: return any(scope_name == root or scope_name.startswith(f"{root}.") for root in GEN_AI_INSTRUMENTATION_SCOPE_ROOTS) -def _classify_gen_ai_operation( - span: Any, - existing_attributes: Mapping[str, object], - baggage_map: Mapping[str, object], -) -> str | None: - """Resolve the GenAI operation a span represents, or ``None`` if unknown.""" - if GEN_AI_OPERATION_NAME_KEY in existing_attributes: - return _recognized_operation_name(existing_attributes.get(GEN_AI_OPERATION_NAME_KEY)) - - baggage_operation_name = _recognized_operation_name(baggage_map.get(GEN_AI_OPERATION_NAME_KEY)) - if baggage_operation_name is not None: - return baggage_operation_name - - return _operation_name_from_span_name(span) - - def _classify_gen_ai_span( span: Any, existing_attributes: Mapping[str, object], baggage_map: Mapping[str, object], ) -> _GenAISpanClassification: - operation_name = _classify_gen_ai_operation(span, existing_attributes, baggage_map) + """Resolve whether a span is GenAI and which operation it represents. + + An explicit ``gen_ai.operation.name`` attribute is authoritative over the + baggage and span-name inference signals, including when it holds a value + this processor does not model (``chain``, ``embeddings``, + ``text_completion``, ``generate_content``, ``create_agent``). Such a span is + still GenAI when a supported instrumentation emitted it, but its operation + stays unknown so invoke_agent-only attributes are withheld. + """ + if GEN_AI_OPERATION_NAME_KEY in existing_attributes: + explicit_operation_name = _recognized_operation_name(existing_attributes.get(GEN_AI_OPERATION_NAME_KEY)) + if explicit_operation_name is not None: + return _GenAISpanClassification(True, explicit_operation_name) + return _GenAISpanClassification(_is_supported_gen_ai_scope(span)) + + operation_name = _recognized_operation_name(baggage_map.get(GEN_AI_OPERATION_NAME_KEY)) + if operation_name is None: + operation_name = _operation_name_from_span_name(span) if operation_name is not None: return _GenAISpanClassification(True, operation_name) - if GEN_AI_OPERATION_NAME_KEY in existing_attributes: - # The span declared an operation this processor does not handle. - return _GenAISpanClassification(False) - return _GenAISpanClassification(_has_known_initial_span_name(span) or _is_supported_gen_ai_scope(span)) -def _is_gen_ai_span(span: Any, existing_attributes: Mapping[str, object], baggage_map: Mapping[str, object]) -> bool: - return _classify_gen_ai_span(span, existing_attributes, baggage_map).is_gen_ai_span - - def _custom_baggage_keys(baggage_map) -> list[str]: metadata = baggage_map.get(CUSTOM_KEYS_BAGGAGE_KEY) if not metadata: diff --git a/tests/a365/test_span_processor.py b/tests/a365/test_span_processor.py index 4871eccb..d20d81f3 100644 --- a/tests/a365/test_span_processor.py +++ b/tests/a365/test_span_processor.py @@ -16,6 +16,22 @@ from microsoft.opentelemetry.a365.core.middleware.baggage_builder import BaggageBuilder from microsoft.opentelemetry.a365.core.constants import GEN_AI_OPERATION_NAME_KEY +LANGCHAIN_SCOPE = "microsoft.opentelemetry._genai._langchain._tracer_instrumentor" +OPENAI_AGENTS_SCOPE = "microsoft.opentelemetry._genai._openai_agents._trace_instrumentor" +UPSTREAM_OPENAI_AGENTS_SCOPE = "opentelemetry.instrumentation.openai_agents" +OPENAI_V2_SCOPE = "opentelemetry.instrumentation.openai_v2" +SEMANTIC_KERNEL_SCOPE = "semantic_kernel.utils.telemetry.model_diagnostics.decorators" +AGENT_FRAMEWORK_SCOPE = "agent_framework" + + +def _mock_span(name, attributes=None, scope_name=None): + """Build a mock ReadWriteSpan with a controllable instrumentation scope.""" + span = MagicMock() + span.name = name + span.attributes = dict(attributes or {}) + span.instrumentation_scope = SimpleNamespace(name=scope_name, version=None) if scope_name else None + return span + class TestA365SpanProcessor(unittest.TestCase): # -- identity auto-stamping from constructor -- @@ -291,6 +307,124 @@ def test_custom_baggage_attribute_ignored_for_scope_prefix_without_boundary(self for call in span.set_attribute.call_args_list: self.assertNotEqual(call[0][0], "customer.tier") + # -- explicit but unrecognized operation names -- + + def test_custom_baggage_propagated_for_explicit_chain_operation_on_openai_agents_scope(self): + """OpenAI Agents emits ``chain`` spans this processor does not model.""" + processor = A365SpanProcessor() + span = _mock_span( + "chain RunnableSequence", + {GEN_AI_OPERATION_NAME_KEY: "chain"}, + scope_name=OPENAI_AGENTS_SCOPE, + ) + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + span.set_attribute.assert_any_call("customer.tier", "gold") + + def test_custom_baggage_propagated_for_explicit_embeddings_operation_on_agent_framework_scope(self): + processor = A365SpanProcessor() + span = _mock_span( + "embeddings text-embedding-3-small", + {GEN_AI_OPERATION_NAME_KEY: "embeddings"}, + scope_name=AGENT_FRAMEWORK_SCOPE, + ) + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + span.set_attribute.assert_any_call("customer.tier", "gold") + + def test_custom_baggage_propagated_for_explicit_text_completion_operation_on_openai_v2_scope(self): + processor = A365SpanProcessor() + span = _mock_span( + "text_completion gpt-4o", + {GEN_AI_OPERATION_NAME_KEY: "text_completion"}, + scope_name=OPENAI_V2_SCOPE, + ) + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + span.set_attribute.assert_any_call("customer.tier", "gold") + + def test_custom_baggage_propagated_for_other_unrecognized_operations_on_supported_scopes(self): + for scope_name, operation_name in ( + (UPSTREAM_OPENAI_AGENTS_SCOPE, "create_agent"), + (LANGCHAIN_SCOPE, "generate_content"), + (SEMANTIC_KERNEL_SCOPE, "embeddings"), + ): + with self.subTest(scope_name=scope_name, operation_name=operation_name): + processor = A365SpanProcessor() + span = _mock_span( + f"{operation_name} target", + {GEN_AI_OPERATION_NAME_KEY: operation_name}, + scope_name=scope_name, + ) + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + span.set_attribute.assert_any_call("customer.tier", "gold") + + def test_custom_baggage_ignored_for_unrecognized_explicit_operation_on_unrelated_scope(self): + processor = A365SpanProcessor() + span = _mock_span( + "POST /v1/chain", + {GEN_AI_OPERATION_NAME_KEY: "chain"}, + scope_name="opentelemetry.instrumentation.requests", + ) + + with BaggageBuilder().custom_attribute("customer.tier", "gold").build(): + processor.on_start(span, parent_context=context.get_current()) + + for call in span.set_attribute.call_args_list: + self.assertNotEqual(call[0][0], "customer.tier") + + def test_unrecognized_explicit_operation_with_invoke_agent_span_name_is_not_invoke_agent(self): + """An unrecognized operation stays unknown, so invoke-only keys are withheld.""" + processor = A365SpanProcessor() + span = _mock_span( + "invoke_agent Travel_Assistant", + {GEN_AI_OPERATION_NAME_KEY: "create_agent"}, + scope_name=OPENAI_AGENTS_SCOPE, + ) + + with ( + BaggageBuilder() + .set_pairs( + { + "microsoft.a365.caller.agent.id": "caller-1", + "server.address": "agent.contoso.com", + } + ) + .custom_attribute("customer.tier", "gold") + .build() + ): + processor.on_start(span, parent_context=context.get_current()) + + span.set_attribute.assert_any_call("customer.tier", "gold") + for call in span.set_attribute.call_args_list: + self.assertNotIn(call[0][0], ("microsoft.a365.caller.agent.id", "server.address")) + + def test_unrecognized_explicit_operation_ignores_invoke_agent_baggage_operation(self): + """Baggage inference is skipped once an explicit operation is present.""" + processor = A365SpanProcessor() + span = _mock_span( + "chain RunnableSequence", + {GEN_AI_OPERATION_NAME_KEY: "chain"}, + scope_name=LANGCHAIN_SCOPE, + ) + + ctx = baggage.set_baggage(GEN_AI_OPERATION_NAME_KEY, "invoke_agent", context.get_current()) + ctx = baggage.set_baggage("microsoft.a365.caller.agent.id", "caller-1", ctx) + + processor.on_start(span, parent_context=ctx) + + for call in span.set_attribute.call_args_list: + self.assertNotEqual(call[0][0], "microsoft.a365.caller.agent.id") + def test_invoke_agent_attributes_use_recognized_baggage_operation_name(self): processor = A365SpanProcessor() From dd4602ce91b7353a2200e277bfeb9ccd78f5130b Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Thu, 17 Sep 2026 12:14:54 -0600 Subject: [PATCH 09/17] Fix custom baggage scope metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a365/core/middleware/baggage_builder.py | 10 ++++++- tests/a365/test_baggage_builder.py | 29 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py b/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py index caa1d6df..64f2ade5 100644 --- a/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py +++ b/src/microsoft/opentelemetry/a365/core/middleware/baggage_builder.py @@ -238,7 +238,10 @@ def set_pairs(self, pairs: Any) -> "BaggageBuilder": for k, v in iterator: if v is None: continue - self._set(str(k), str(v)) + key = str(k) + if key == CUSTOM_KEYS_BAGGAGE_KEY: + continue + self._set(key, str(v)) return self def custom_attribute(self, key: str, value: str | None) -> "BaggageBuilder": @@ -324,6 +327,11 @@ def __enter__(self) -> "BaggageScope": new_context = self._previous_context for key, value in self._pairs.items(): if value and value.strip(): + if key == CUSTOM_KEYS_BAGGAGE_KEY: + inherited_value = baggage.get_baggage(key, context=new_context) + inherited_keys = str(inherited_value).split(",") if inherited_value else [] + current_keys = value.split(",") + value = ",".join(dict.fromkeys(inherited_keys + current_keys)) new_context = baggage.set_baggage(key, value, context=new_context) # Attach the new context diff --git a/tests/a365/test_baggage_builder.py b/tests/a365/test_baggage_builder.py index eb8db7cf..7949de87 100644 --- a/tests/a365/test_baggage_builder.py +++ b/tests/a365/test_baggage_builder.py @@ -47,6 +47,35 @@ def test_set_pairs_does_not_mark_custom_metadata(): assert baggage.get_baggage(CUSTOM_KEYS_BAGGAGE_KEY) is None +def test_set_pairs_does_not_accept_custom_metadata(): + pairs = { + "customer.tier": "gold", + CUSTOM_KEYS_BAGGAGE_KEY: "customer.tier", + } + + with BaggageBuilder().set_pairs(pairs).build(): + assert baggage.get_baggage("customer.tier") == "gold" + assert baggage.get_baggage(CUSTOM_KEYS_BAGGAGE_KEY) is None + + +def test_nested_custom_attributes_preserve_outer_metadata(): + outer_attributes = { + "customer.tier": "gold", + "customer.region": "west", + } + inner_attributes = { + "customer.region": "east", + "customer.segment": "enterprise", + } + + with BaggageBuilder().custom_attributes(outer_attributes).build(): + with BaggageBuilder().custom_attributes(inner_attributes).build(): + assert baggage.get_baggage("customer.tier") == "gold" + assert baggage.get_baggage("customer.region") == "east" + assert baggage.get_baggage("customer.segment") == "enterprise" + assert baggage.get_baggage(CUSTOM_KEYS_BAGGAGE_KEY) == "customer.tier,customer.region,customer.segment" + + def test_baggage_scope_restores_previous_context(): token = context.attach(baggage.set_baggage("customer.tier", "silver")) try: From 7ab71ec967abbd0d74779d4270468851b3de2ec9 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 10:29:22 -0600 Subject: [PATCH 10/17] Refactor GenAI span classification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/exporters/_gen_ai_span_classifier.py | 103 ++++++++++++++++++ .../a365/core/exporters/span_processor.py | 96 +--------------- tests/a365/test_gen_ai_span_classifier.py | 25 +++++ 3 files changed, 129 insertions(+), 95 deletions(-) create mode 100644 src/microsoft/opentelemetry/a365/core/exporters/_gen_ai_span_classifier.py create mode 100644 tests/a365/test_gen_ai_span_classifier.py diff --git a/src/microsoft/opentelemetry/a365/core/exporters/_gen_ai_span_classifier.py b/src/microsoft/opentelemetry/a365/core/exporters/_gen_ai_span_classifier.py new file mode 100644 index 00000000..9bb6aeef --- /dev/null +++ b/src/microsoft/opentelemetry/a365/core/exporters/_gen_ai_span_classifier.py @@ -0,0 +1,103 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Classify spans for Agent365 GenAI baggage propagation.""" + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from microsoft.opentelemetry.a365.core.constants import ( + GEN_AI_INITIAL_SPAN_NAMES, + GEN_AI_INSTRUMENTATION_SCOPE_ROOTS, + GEN_AI_OPERATION_NAME_KEY, + GEN_AI_PROCESSOR_OPERATION_NAMES, +) + + +@dataclass(frozen=True) +class _GenAISpanClassification: + """Whether a span is GenAI and, when identifiable, the operation it represents.""" + + is_gen_ai_span: bool + operation_name: str | None = None + + +def _recognized_operation_name(value: object | None) -> str | None: + return value if isinstance(value, str) and value in GEN_AI_PROCESSOR_OPERATION_NAMES else None + + +def _span_name(span: Any) -> str | None: + span_name = getattr(span, "name", None) + return span_name if isinstance(span_name, str) else None + + +def _operation_name_from_span_name(span: Any) -> str | None: + span_name = _span_name(span) + if span_name is None: + return None + + for operation_name in GEN_AI_PROCESSOR_OPERATION_NAMES: + if span_name == operation_name or span_name.startswith(f"{operation_name} "): + return operation_name + return None + + +def _has_known_initial_span_name(span: Any) -> bool: + """Match span names supported instrumentations use before renaming the span.""" + span_name = _span_name(span) + if span_name is None: + return False + + return any(span_name == known or span_name.startswith(f"{known} ") for known in GEN_AI_INITIAL_SPAN_NAMES) + + +def _instrumentation_scope_name(span: Any) -> str | None: + """Read the tracer (source) name recorded on a ReadWriteSpan.""" + # pylint: disable=broad-exception-caught + for attribute_name in ("instrumentation_scope", "instrumentation_info"): + try: + scope = getattr(span, attribute_name, None) + scope_name = getattr(scope, "name", None) if scope is not None else None + except Exception: + continue + if isinstance(scope_name, str) and scope_name: + return scope_name + return None + + +def _is_supported_gen_ai_scope(span: Any) -> bool: + scope_name = _instrumentation_scope_name(span) + if scope_name is None: + return False + + return any(scope_name == root or scope_name.startswith(f"{root}.") for root in GEN_AI_INSTRUMENTATION_SCOPE_ROOTS) + + +def _classify_gen_ai_span( + span: Any, + existing_attributes: Mapping[str, object], + baggage_map: Mapping[str, object], +) -> _GenAISpanClassification: + """Resolve whether a span is GenAI and which operation it represents. + + An explicit ``gen_ai.operation.name`` attribute is authoritative over the + baggage and span-name inference signals, including when it holds a value + this processor does not model (``chain``, ``embeddings``, + ``text_completion``, ``generate_content``, ``create_agent``). Such a span is + still GenAI when a supported instrumentation emitted it, but its operation + stays unknown so invoke_agent-only attributes are withheld. + """ + if GEN_AI_OPERATION_NAME_KEY in existing_attributes: + explicit_operation_name = _recognized_operation_name(existing_attributes.get(GEN_AI_OPERATION_NAME_KEY)) + if explicit_operation_name is not None: + return _GenAISpanClassification(True, explicit_operation_name) + return _GenAISpanClassification(_is_supported_gen_ai_scope(span)) + + operation_name = _recognized_operation_name(baggage_map.get(GEN_AI_OPERATION_NAME_KEY)) + if operation_name is None: + operation_name = _operation_name_from_span_name(span) + if operation_name is not None: + return _GenAISpanClassification(True, operation_name) + + return _GenAISpanClassification(_has_known_initial_span_name(span) or _is_supported_gen_ai_scope(span)) diff --git a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py index 14a2f849..543b04d1 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py @@ -41,13 +41,10 @@ from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass -from typing import Any - from opentelemetry import baggage, context from opentelemetry.sdk.trace import SpanProcessor as BaseSpanProcessor +from microsoft.opentelemetry.a365.core.exporters._gen_ai_span_classifier import _classify_gen_ai_span from microsoft.opentelemetry.a365.core.constants import ( CHANNEL_LINK_KEY, CHANNEL_NAME_KEY, @@ -72,10 +69,7 @@ GEN_AI_CALLER_CLIENT_IP_KEY, GEN_AI_CONVERSATION_ID_KEY, GEN_AI_CONVERSATION_ITEM_LINK_KEY, - GEN_AI_INITIAL_SPAN_NAMES, - GEN_AI_INSTRUMENTATION_SCOPE_ROOTS, GEN_AI_OPERATION_NAME_KEY, - GEN_AI_PROCESSOR_OPERATION_NAMES, INVOKE_AGENT_OPERATION_NAME, SERVER_ADDRESS_KEY, SERVER_PORT_KEY, @@ -131,94 +125,6 @@ ] -@dataclass(frozen=True) -class _GenAISpanClassification: - """Whether a span is GenAI and, when identifiable, the operation it represents.""" - - is_gen_ai_span: bool - operation_name: str | None = None - - -def _recognized_operation_name(value: object | None) -> str | None: - return value if isinstance(value, str) and value in GEN_AI_PROCESSOR_OPERATION_NAMES else None - - -def _span_name(span: Any) -> str | None: - span_name = getattr(span, "name", None) - return span_name if isinstance(span_name, str) else None - - -def _operation_name_from_span_name(span: Any) -> str | None: - span_name = _span_name(span) - if span_name is None: - return None - - for operation_name in GEN_AI_PROCESSOR_OPERATION_NAMES: - if span_name == operation_name or span_name.startswith(f"{operation_name} "): - return operation_name - return None - - -def _has_known_initial_span_name(span: Any) -> bool: - """Match span names supported instrumentations use before renaming the span.""" - span_name = _span_name(span) - if span_name is None: - return False - - return any(span_name == known or span_name.startswith(f"{known} ") for known in GEN_AI_INITIAL_SPAN_NAMES) - - -def _instrumentation_scope_name(span: Any) -> str | None: - """Read the tracer (source) name recorded on a ReadWriteSpan.""" - # pylint: disable=broad-exception-caught - for attribute_name in ("instrumentation_scope", "instrumentation_info"): - try: - scope = getattr(span, attribute_name, None) - scope_name = getattr(scope, "name", None) if scope is not None else None - except Exception: - continue - if isinstance(scope_name, str) and scope_name: - return scope_name - return None - - -def _is_supported_gen_ai_scope(span: Any) -> bool: - scope_name = _instrumentation_scope_name(span) - if scope_name is None: - return False - - return any(scope_name == root or scope_name.startswith(f"{root}.") for root in GEN_AI_INSTRUMENTATION_SCOPE_ROOTS) - - -def _classify_gen_ai_span( - span: Any, - existing_attributes: Mapping[str, object], - baggage_map: Mapping[str, object], -) -> _GenAISpanClassification: - """Resolve whether a span is GenAI and which operation it represents. - - An explicit ``gen_ai.operation.name`` attribute is authoritative over the - baggage and span-name inference signals, including when it holds a value - this processor does not model (``chain``, ``embeddings``, - ``text_completion``, ``generate_content``, ``create_agent``). Such a span is - still GenAI when a supported instrumentation emitted it, but its operation - stays unknown so invoke_agent-only attributes are withheld. - """ - if GEN_AI_OPERATION_NAME_KEY in existing_attributes: - explicit_operation_name = _recognized_operation_name(existing_attributes.get(GEN_AI_OPERATION_NAME_KEY)) - if explicit_operation_name is not None: - return _GenAISpanClassification(True, explicit_operation_name) - return _GenAISpanClassification(_is_supported_gen_ai_scope(span)) - - operation_name = _recognized_operation_name(baggage_map.get(GEN_AI_OPERATION_NAME_KEY)) - if operation_name is None: - operation_name = _operation_name_from_span_name(span) - if operation_name is not None: - return _GenAISpanClassification(True, operation_name) - - return _GenAISpanClassification(_has_known_initial_span_name(span) or _is_supported_gen_ai_scope(span)) - - def _custom_baggage_keys(baggage_map) -> list[str]: metadata = baggage_map.get(CUSTOM_KEYS_BAGGAGE_KEY) if not metadata: diff --git a/tests/a365/test_gen_ai_span_classifier.py b/tests/a365/test_gen_ai_span_classifier.py new file mode 100644 index 00000000..c17093dc --- /dev/null +++ b/tests/a365/test_gen_ai_span_classifier.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from types import SimpleNamespace + +from microsoft.opentelemetry.a365.core.constants import GEN_AI_OPERATION_NAME_KEY +from microsoft.opentelemetry.a365.core.exporters._gen_ai_span_classifier import ( + _classify_gen_ai_span, +) + + +def test_classifies_supported_scope_with_unknown_operation_as_gen_ai(): + span = SimpleNamespace( + name="chain RunnableSequence", + instrumentation_scope=SimpleNamespace(name="microsoft.opentelemetry._genai._langchain"), + ) + + classification = _classify_gen_ai_span( + span, + {GEN_AI_OPERATION_NAME_KEY: "chain"}, + {}, + ) + + assert classification.is_gen_ai_span + assert classification.operation_name is None From 0b3c5b0887252d73394c3fb53ebba6e723245a7f Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 10:38:49 -0600 Subject: [PATCH 11/17] Address custom baggage review comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 +++ src/microsoft/opentelemetry/a365/core/constants.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d9233d2..64927f03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,16 @@ - Add explicit `BaggageBuilder.custom_attribute()` and `custom_attributes()` APIs for opting application-specific baggage keys into Agent365 GenAI span attributes. + ([#264](https://github.com/microsoft/opentelemetry-distro-python/pull/264)) - Recognize Agent365 and inference operation names when deciding whether opted-in custom baggage applies to a span. + ([#264](https://github.com/microsoft/opentelemetry-distro-python/pull/264)) - Keep spans that declare an operation the processor does not model (`chain`, `embeddings`, `text_completion`, `generate_content`, `create_agent`) eligible for custom baggage when a supported GenAI instrumentation scope emitted them. Such spans stay operation-unknown, so `invoke_agent`-only attributes are still withheld. + ([#264](https://github.com/microsoft/opentelemetry-distro-python/pull/264)) - Add Python-native `InvokeAgentScope` request and response parameter models that emit OpenTelemetry GenAI semantic attributes, including structured system instructions and cache read/write token counts, introduced by .NET diff --git a/src/microsoft/opentelemetry/a365/core/constants.py b/src/microsoft/opentelemetry/a365/core/constants.py index f4771e65..adbc64cd 100644 --- a/src/microsoft/opentelemetry/a365/core/constants.py +++ b/src/microsoft/opentelemetry/a365/core/constants.py @@ -60,6 +60,8 @@ "microsoft.opentelemetry._genai", # Upstream OpenAI instrumentations supported by this distro. "opentelemetry.instrumentation.openai_v2", + # The ``opentelemetry-instrumentation-openai-agents-v2`` distribution emits + # this scope without the distribution's ``-v2`` suffix. "opentelemetry.instrumentation.openai_agents", ) From 0cb4d44380716a3a1ee052309418ac4850ac62d5 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 10:43:54 -0600 Subject: [PATCH 12/17] Test all GenAI instrumentation scope roots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/a365/test_gen_ai_span_classifier.py | 34 ++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/tests/a365/test_gen_ai_span_classifier.py b/tests/a365/test_gen_ai_span_classifier.py index c17093dc..41110476 100644 --- a/tests/a365/test_gen_ai_span_classifier.py +++ b/tests/a365/test_gen_ai_span_classifier.py @@ -3,7 +3,12 @@ from types import SimpleNamespace -from microsoft.opentelemetry.a365.core.constants import GEN_AI_OPERATION_NAME_KEY +import pytest + +from microsoft.opentelemetry.a365.core.constants import ( + GEN_AI_INSTRUMENTATION_SCOPE_ROOTS, + GEN_AI_OPERATION_NAME_KEY, +) from microsoft.opentelemetry.a365.core.exporters._gen_ai_span_classifier import ( _classify_gen_ai_span, ) @@ -23,3 +28,30 @@ def test_classifies_supported_scope_with_unknown_operation_as_gen_ai(): assert classification.is_gen_ai_span assert classification.operation_name is None + + +@pytest.mark.parametrize("root", GEN_AI_INSTRUMENTATION_SCOPE_ROOTS) +@pytest.mark.parametrize("suffix", ["", ".instrumentation"]) +def test_classifies_every_supported_scope_root_and_dotted_child(root, suffix): + span = SimpleNamespace( + name="unmodeled operation", + instrumentation_scope=SimpleNamespace(name=f"{root}{suffix}"), + ) + + classification = _classify_gen_ai_span(span, {}, {}) + + assert classification.is_gen_ai_span + assert classification.operation_name is None + + +@pytest.mark.parametrize("root", GEN_AI_INSTRUMENTATION_SCOPE_ROOTS) +def test_rejects_scope_root_prefix_without_dotted_boundary(root): + span = SimpleNamespace( + name="unmodeled operation", + instrumentation_scope=SimpleNamespace(name=f"{root}_helpers"), + ) + + classification = _classify_gen_ai_span(span, {}, {}) + + assert not classification.is_gen_ai_span + assert classification.operation_name is None From 3998c70efc44572aea256e900c4db2e9f440789e Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 10:48:06 -0600 Subject: [PATCH 13/17] Reduce GenAI scope comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../opentelemetry/a365/core/constants.py | 27 +++---------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/src/microsoft/opentelemetry/a365/core/constants.py b/src/microsoft/opentelemetry/a365/core/constants.py index adbc64cd..150fda42 100644 --- a/src/microsoft/opentelemetry/a365/core/constants.py +++ b/src/microsoft/opentelemetry/a365/core/constants.py @@ -37,39 +37,18 @@ AZURE_RP_NAMESPACE_VALUE = "Microsoft.CognitiveServices" SOURCE_NAME = "Agent365Sdk" -# --- GenAI instrumentation recognition (span-start signals) --- -# ``gen_ai.operation.name`` is frequently applied *after* a span starts: -# LangChain and the OpenAI Agents processor set it (and rename the span) when -# the run finishes, and Semantic Kernel / Agent Framework call -# ``span.set_attributes`` on the line following ``start_span``. A span -# processor's ``on_start`` hook therefore cannot rely on that attribute alone. -# -# ``ReadWriteSpan.instrumentation_scope`` *is* populated at ``on_start``, so the -# tracer (source) name of a supported GenAI instrumentation is used as an -# additional positive signal. A scope matches when it equals a root exactly or -# is a dotted child of it, which keeps unrelated instrumentations (HTTP, DB, -# web frameworks) and lookalike names such as ``semantic_kernel_helpers`` out. +# Identify GenAI spans before instrumentations set ``gen_ai.operation.name``. +# Scope matching accepts an exact root or dotted child. GEN_AI_INSTRUMENTATION_SCOPE_ROOTS: tuple[str, ...] = ( - # Agent365 SDK scopes (``OpenTelemetryScope``). SOURCE_NAME, - # Microsoft Agent Framework SDK (``get_tracer("agent_framework")``). "agent_framework", - # Semantic Kernel SDK (model/agent/function diagnostics use ``__name__``). "semantic_kernel", - # In-distro LangChain and OpenAI Agents tracers. "microsoft.opentelemetry._genai", - # Upstream OpenAI instrumentations supported by this distro. "opentelemetry.instrumentation.openai_v2", - # The ``opentelemetry-instrumentation-openai-agents-v2`` distribution emits - # this scope without the distribution's ``-v2`` suffix. "opentelemetry.instrumentation.openai_agents", ) -# Span names emitted by supported GenAI instrumentations before they rename the -# span. Semantic Kernel <= 1.37 starts inference spans as -# ``chat.completions `` / ``text.completions `` and >= 1.38 as -# ``text_completions ``; matching is exact or up to a trailing space so -# nearby names such as ``chat.completions.retry`` are not claimed. +# Initial names used by supported GenAI instrumentations before span renaming. GEN_AI_INITIAL_SPAN_NAMES: frozenset[str] = frozenset( { "chat.completions", From ffecbf8952c0ed3229e96abaceb060dc685d21a4 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 18 Sep 2026 10:51:22 -0600 Subject: [PATCH 14/17] Simplify custom baggage changelog entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64927f03..1d637130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,19 +1,7 @@ # Release History # Unreleased ### Features Added -- Add explicit `BaggageBuilder.custom_attribute()` and `custom_attributes()` - APIs for opting application-specific baggage keys into Agent365 GenAI span - attributes. - ([#264](https://github.com/microsoft/opentelemetry-distro-python/pull/264)) -- Recognize Agent365 and inference operation names when deciding whether - opted-in custom baggage applies to a span. - ([#264](https://github.com/microsoft/opentelemetry-distro-python/pull/264)) -- Keep spans that declare an operation the processor does not model (`chain`, - `embeddings`, `text_completion`, `generate_content`, `create_agent`) eligible - for custom baggage when a supported GenAI instrumentation scope emitted them. - Such spans stay operation-unknown, so `invoke_agent`-only attributes are still - withheld. - ([#264](https://github.com/microsoft/opentelemetry-distro-python/pull/264)) +- Add explicit custom baggage APIs and propagate opted-in attributes to supported GenAI spans, including unmodeled operations. ([#264](https://github.com/microsoft/opentelemetry-distro-python/pull/264)) - Add Python-native `InvokeAgentScope` request and response parameter models that emit OpenTelemetry GenAI semantic attributes, including structured system instructions and cache read/write token counts, introduced by .NET From 026cfa2ce81ae265fdf1c4673f66b753de5aceb5 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 22 Sep 2026 12:57:03 -0600 Subject: [PATCH 15/17] Document GenAI operation precedence fix Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...09-22-genai-operation-precedence-design.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-22-genai-operation-precedence-design.md diff --git a/docs/superpowers/specs/2026-09-22-genai-operation-precedence-design.md b/docs/superpowers/specs/2026-09-22-genai-operation-precedence-design.md new file mode 100644 index 00000000..7184819c --- /dev/null +++ b/docs/superpowers/specs/2026-09-22-genai-operation-precedence-design.md @@ -0,0 +1,39 @@ +# GenAI Operation Classification Precedence + +## Goal + +Prevent a span whose name identifies one supported GenAI operation from being +classified as another operation solely because it inherits +`gen_ai.operation.name` baggage. + +## Classification precedence + +When `A365SpanProcessor.on_start()` classifies a span: + +1. An explicit `gen_ai.operation.name` span attribute remains authoritative. +2. A recognized operation in the span name takes precedence over operation + baggage. +3. A recognized baggage operation is used only when the span name does not + identify a supported operation. +4. Known initial span names and supported instrumentation scopes continue to + identify GenAI spans without assigning a modeled operation when appropriate. + +This changes only the relative precedence of recognized span names and baggage. +It preserves explicit attribute precedence, custom baggage propagation, and +scope-based GenAI classification. + +## Regression coverage + +Add a processor test for an `execute_tool ...` span created under context that +contains `gen_ai.operation.name=invoke_agent`, invoke-only caller baggage, and +an opted-in custom baggage key. The test must verify: + +- invoke-only caller and endpoint attributes are not copied to the tool span; +- the opted-in custom attribute is still copied because the span remains GenAI; +- existing tests continue to cover baggage inference when the span name does + not identify an operation. + +## Scope + +The change is limited to the GenAI span classifier and focused processor tests. +No baggage-builder API, metadata format, or public API changes are required. From b3e4e68d9cc959a14779f0a492754a03823cf127 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 22 Sep 2026 13:21:53 -0600 Subject: [PATCH 16/17] Fix GenAI operation classification precedence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-09-22-genai-operation-precedence.md | 109 ++++++++++++++++++ .../core/exporters/_gen_ai_span_classifier.py | 7 +- tests/a365/test_span_processor.py | 22 ++++ 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-22-genai-operation-precedence.md diff --git a/docs/superpowers/plans/2026-09-22-genai-operation-precedence.md b/docs/superpowers/plans/2026-09-22-genai-operation-precedence.md new file mode 100644 index 00000000..2f2d186a --- /dev/null +++ b/docs/superpowers/plans/2026-09-22-genai-operation-precedence.md @@ -0,0 +1,109 @@ +# GenAI Operation Precedence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Prevent inherited operation baggage from overriding a supported GenAI operation identified by the span name. + +**Architecture:** Keep explicit `gen_ai.operation.name` span attributes authoritative. When the attribute is absent, classify a recognized operation from the span name before consulting inherited operation baggage; retain existing initial-name and instrumentation-scope fallbacks. + +**Tech Stack:** Python 3.10+, OpenTelemetry API/SDK, pytest, unittest mocks + +## Global Constraints + +- Change only GenAI classification precedence and focused regression coverage. +- Preserve explicit operation-attribute precedence. +- Preserve custom baggage propagation to recognized GenAI spans. +- Preserve baggage operation inference for spans whose names do not identify a supported operation. +- Do not change the baggage-builder API, metadata format, or public APIs. + +--- + +### Task 1: Prioritize recognized span operations over inherited baggage + +**Files:** +- Modify: `src/microsoft/opentelemetry/a365/core/exporters/_gen_ai_span_classifier.py:91-99` +- Test: `tests/a365/test_span_processor.py` + +**Interfaces:** +- Consumes: `_operation_name_from_span_name(span: Any) -> str | None` and `_recognized_operation_name(value: object | None) -> str | None` +- Produces: `_classify_gen_ai_span(...) -> _GenAISpanClassification` with span-name operation precedence over baggage when no explicit operation attribute exists + +- [ ] **Step 1: Write the failing regression test** + +Add this test near the existing baggage-operation classification tests: + +```python +def test_span_name_operation_takes_precedence_over_inherited_baggage_operation(self): + processor = A365SpanProcessor() + span = _mock_span("execute_tool get_weather") + + with ( + BaggageBuilder() + .set_pairs( + { + GEN_AI_OPERATION_NAME_KEY: "invoke_agent", + "microsoft.a365.caller.agent.id": "caller-1", + "server.address": "agent.contoso.com", + } + ) + .custom_attribute("customer.tier", "gold") + .build() + ): + processor.on_start(span, parent_context=context.get_current()) + + span.set_attribute.assert_any_call("customer.tier", "gold") + for call in span.set_attribute.call_args_list: + self.assertNotIn(call[0][0], ("microsoft.a365.caller.agent.id", "server.address")) +``` + +- [ ] **Step 2: Run the regression test and verify it fails** + +Run: + +```powershell +python -m pytest tests\a365\test_span_processor.py::TestA365SpanProcessor::test_span_name_operation_takes_precedence_over_inherited_baggage_operation -v +``` + +Expected: FAIL because `microsoft.a365.caller.agent.id` or `server.address` is copied after inherited `invoke_agent` baggage wins classification. + +- [ ] **Step 3: Implement the precedence change** + +Replace the non-explicit classification block with: + +```python +operation_name = _operation_name_from_span_name(span) +if operation_name is None: + operation_name = _recognized_operation_name(baggage_map.get(GEN_AI_OPERATION_NAME_KEY)) +if operation_name is not None: + return _GenAISpanClassification(True, operation_name) +``` + +Update the classifier docstring to state that, when there is no explicit operation attribute, recognized span names take precedence over baggage operation inference. + +- [ ] **Step 4: Run focused processor and classifier tests** + +Run: + +```powershell +python -m pytest tests\a365\test_span_processor.py tests\a365\test_gen_ai_span_classifier.py -v +``` + +Expected: all selected tests PASS. + +- [ ] **Step 5: Run formatting and diff validation** + +Run: + +```powershell +python -m black --check src\microsoft\opentelemetry\a365\core\exporters\_gen_ai_span_classifier.py tests\a365\test_span_processor.py +git diff --check +``` + +Expected: both commands exit successfully with no formatting or whitespace errors. + +- [ ] **Step 6: Commit the fix** + +```powershell +git add src\microsoft\opentelemetry\a365\core\exporters\_gen_ai_span_classifier.py tests\a365\test_span_processor.py docs\superpowers\plans\2026-09-22-genai-operation-precedence.md +git commit -m "Fix GenAI operation classification precedence" -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` diff --git a/src/microsoft/opentelemetry/a365/core/exporters/_gen_ai_span_classifier.py b/src/microsoft/opentelemetry/a365/core/exporters/_gen_ai_span_classifier.py index 9bb6aeef..94368289 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/_gen_ai_span_classifier.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/_gen_ai_span_classifier.py @@ -87,6 +87,9 @@ def _classify_gen_ai_span( ``text_completion``, ``generate_content``, ``create_agent``). Such a span is still GenAI when a supported instrumentation emitted it, but its operation stays unknown so invoke_agent-only attributes are withheld. + + Without an explicit operation attribute, a recognized span-name operation + takes precedence over inherited operation baggage. """ if GEN_AI_OPERATION_NAME_KEY in existing_attributes: explicit_operation_name = _recognized_operation_name(existing_attributes.get(GEN_AI_OPERATION_NAME_KEY)) @@ -94,9 +97,9 @@ def _classify_gen_ai_span( return _GenAISpanClassification(True, explicit_operation_name) return _GenAISpanClassification(_is_supported_gen_ai_scope(span)) - operation_name = _recognized_operation_name(baggage_map.get(GEN_AI_OPERATION_NAME_KEY)) + operation_name = _operation_name_from_span_name(span) if operation_name is None: - operation_name = _operation_name_from_span_name(span) + operation_name = _recognized_operation_name(baggage_map.get(GEN_AI_OPERATION_NAME_KEY)) if operation_name is not None: return _GenAISpanClassification(True, operation_name) diff --git a/tests/a365/test_span_processor.py b/tests/a365/test_span_processor.py index d20d81f3..da1d8e5b 100644 --- a/tests/a365/test_span_processor.py +++ b/tests/a365/test_span_processor.py @@ -440,6 +440,28 @@ def test_invoke_agent_attributes_use_recognized_baggage_operation_name(self): span.set_attribute.assert_any_call("microsoft.a365.caller.agent.id", "caller-1") + def test_span_name_operation_takes_precedence_over_inherited_baggage_operation(self): + processor = A365SpanProcessor() + span = _mock_span("execute_tool get_weather") + + with ( + BaggageBuilder() + .set_pairs( + { + GEN_AI_OPERATION_NAME_KEY: "invoke_agent", + "microsoft.a365.caller.agent.id": "caller-1", + "server.address": "agent.contoso.com", + } + ) + .custom_attribute("customer.tier", "gold") + .build() + ): + processor.on_start(span, parent_context=context.get_current()) + + span.set_attribute.assert_any_call("customer.tier", "gold") + for call in span.set_attribute.call_args_list: + self.assertNotIn(call[0][0], ("microsoft.a365.caller.agent.id", "server.address")) + def test_invoke_agent_attributes_ignored_for_raw_prefix_without_boundary(self): processor = A365SpanProcessor() From 09610ab0c37540086150d889264124c98ed8db85 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 22 Sep 2026 14:26:04 -0600 Subject: [PATCH 17/17] Remove ignored planning documents Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../2026-09-22-genai-operation-precedence.md | 109 ------------------ ...09-22-genai-operation-precedence-design.md | 39 ------- 2 files changed, 148 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-22-genai-operation-precedence.md delete mode 100644 docs/superpowers/specs/2026-09-22-genai-operation-precedence-design.md diff --git a/docs/superpowers/plans/2026-09-22-genai-operation-precedence.md b/docs/superpowers/plans/2026-09-22-genai-operation-precedence.md deleted file mode 100644 index 2f2d186a..00000000 --- a/docs/superpowers/plans/2026-09-22-genai-operation-precedence.md +++ /dev/null @@ -1,109 +0,0 @@ -# GenAI Operation Precedence Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Prevent inherited operation baggage from overriding a supported GenAI operation identified by the span name. - -**Architecture:** Keep explicit `gen_ai.operation.name` span attributes authoritative. When the attribute is absent, classify a recognized operation from the span name before consulting inherited operation baggage; retain existing initial-name and instrumentation-scope fallbacks. - -**Tech Stack:** Python 3.10+, OpenTelemetry API/SDK, pytest, unittest mocks - -## Global Constraints - -- Change only GenAI classification precedence and focused regression coverage. -- Preserve explicit operation-attribute precedence. -- Preserve custom baggage propagation to recognized GenAI spans. -- Preserve baggage operation inference for spans whose names do not identify a supported operation. -- Do not change the baggage-builder API, metadata format, or public APIs. - ---- - -### Task 1: Prioritize recognized span operations over inherited baggage - -**Files:** -- Modify: `src/microsoft/opentelemetry/a365/core/exporters/_gen_ai_span_classifier.py:91-99` -- Test: `tests/a365/test_span_processor.py` - -**Interfaces:** -- Consumes: `_operation_name_from_span_name(span: Any) -> str | None` and `_recognized_operation_name(value: object | None) -> str | None` -- Produces: `_classify_gen_ai_span(...) -> _GenAISpanClassification` with span-name operation precedence over baggage when no explicit operation attribute exists - -- [ ] **Step 1: Write the failing regression test** - -Add this test near the existing baggage-operation classification tests: - -```python -def test_span_name_operation_takes_precedence_over_inherited_baggage_operation(self): - processor = A365SpanProcessor() - span = _mock_span("execute_tool get_weather") - - with ( - BaggageBuilder() - .set_pairs( - { - GEN_AI_OPERATION_NAME_KEY: "invoke_agent", - "microsoft.a365.caller.agent.id": "caller-1", - "server.address": "agent.contoso.com", - } - ) - .custom_attribute("customer.tier", "gold") - .build() - ): - processor.on_start(span, parent_context=context.get_current()) - - span.set_attribute.assert_any_call("customer.tier", "gold") - for call in span.set_attribute.call_args_list: - self.assertNotIn(call[0][0], ("microsoft.a365.caller.agent.id", "server.address")) -``` - -- [ ] **Step 2: Run the regression test and verify it fails** - -Run: - -```powershell -python -m pytest tests\a365\test_span_processor.py::TestA365SpanProcessor::test_span_name_operation_takes_precedence_over_inherited_baggage_operation -v -``` - -Expected: FAIL because `microsoft.a365.caller.agent.id` or `server.address` is copied after inherited `invoke_agent` baggage wins classification. - -- [ ] **Step 3: Implement the precedence change** - -Replace the non-explicit classification block with: - -```python -operation_name = _operation_name_from_span_name(span) -if operation_name is None: - operation_name = _recognized_operation_name(baggage_map.get(GEN_AI_OPERATION_NAME_KEY)) -if operation_name is not None: - return _GenAISpanClassification(True, operation_name) -``` - -Update the classifier docstring to state that, when there is no explicit operation attribute, recognized span names take precedence over baggage operation inference. - -- [ ] **Step 4: Run focused processor and classifier tests** - -Run: - -```powershell -python -m pytest tests\a365\test_span_processor.py tests\a365\test_gen_ai_span_classifier.py -v -``` - -Expected: all selected tests PASS. - -- [ ] **Step 5: Run formatting and diff validation** - -Run: - -```powershell -python -m black --check src\microsoft\opentelemetry\a365\core\exporters\_gen_ai_span_classifier.py tests\a365\test_span_processor.py -git diff --check -``` - -Expected: both commands exit successfully with no formatting or whitespace errors. - -- [ ] **Step 6: Commit the fix** - -```powershell -git add src\microsoft\opentelemetry\a365\core\exporters\_gen_ai_span_classifier.py tests\a365\test_span_processor.py docs\superpowers\plans\2026-09-22-genai-operation-precedence.md -git commit -m "Fix GenAI operation classification precedence" -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" -``` diff --git a/docs/superpowers/specs/2026-09-22-genai-operation-precedence-design.md b/docs/superpowers/specs/2026-09-22-genai-operation-precedence-design.md deleted file mode 100644 index 7184819c..00000000 --- a/docs/superpowers/specs/2026-09-22-genai-operation-precedence-design.md +++ /dev/null @@ -1,39 +0,0 @@ -# GenAI Operation Classification Precedence - -## Goal - -Prevent a span whose name identifies one supported GenAI operation from being -classified as another operation solely because it inherits -`gen_ai.operation.name` baggage. - -## Classification precedence - -When `A365SpanProcessor.on_start()` classifies a span: - -1. An explicit `gen_ai.operation.name` span attribute remains authoritative. -2. A recognized operation in the span name takes precedence over operation - baggage. -3. A recognized baggage operation is used only when the span name does not - identify a supported operation. -4. Known initial span names and supported instrumentation scopes continue to - identify GenAI spans without assigning a modeled operation when appropriate. - -This changes only the relative precedence of recognized span names and baggage. -It preserves explicit attribute precedence, custom baggage propagation, and -scope-based GenAI classification. - -## Regression coverage - -Add a processor test for an `execute_tool ...` span created under context that -contains `gen_ai.operation.name=invoke_agent`, invoke-only caller baggage, and -an opted-in custom baggage key. The test must verify: - -- invoke-only caller and endpoint attributes are not copied to the tool span; -- the opted-in custom attribute is still copied because the span remains GenAI; -- existing tests continue to cover baggage inference when the span name does - not identify an operation. - -## Scope - -The change is limited to the GenAI span classifier and focused processor tests. -No baggage-builder API, metadata format, or public API changes are required.