From 95c0d110aa05b4e4194d8319ed9dc1e40b5e7241 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 15:38:29 -0600 Subject: [PATCH 01/10] fix: scope A365 baggage processing to GenAI spans Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a365/core/exporters/span_processor.py | 45 ++++++-- .../a365/core/exporters/utils.py | 4 +- tests/a365/test_exporter.py | 25 +++-- tests/a365/test_span_processor.py | 101 +++++++++++++++--- 4 files changed, 137 insertions(+), 38 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..9408a17f 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py @@ -6,7 +6,7 @@ """Span processor for propagating OpenTelemetry baggage entries onto spans. -For every new span: +For every recognized GenAI span: * Retrieve the current (or parent) context * Obtain all baggage entries * For each documented key with a truthy value not already present as a span @@ -16,6 +16,9 @@ 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 @@ -54,10 +57,11 @@ USER_ID_KEY, USER_NAME_KEY, ) +from microsoft.opentelemetry.a365.core.exporters.utils import GEN_AI_OPERATION_NAMES # mypy: disable-error-code="no-untyped-def" -# Generic / common tracing attributes propagated from baggage to all spans +# Generic / common tracing attributes propagated from baggage to qualifying GenAI spans COMMON_ATTRIBUTES = [ TENANT_ID_KEY, CUSTOM_PARENT_SPAN_ID_KEY, @@ -98,12 +102,36 @@ ] +def _matches_operation_name(span: Any, existing_attributes: Mapping[str, object], operation_name: str) -> bool: + existing_operation_name = existing_attributes.get(GEN_AI_OPERATION_NAME_KEY) + if existing_operation_name: + return existing_operation_name == operation_name + + span_name = getattr(span, "name", None) + return isinstance(span_name, str) and (span_name == operation_name or span_name.startswith(f"{operation_name} ")) + + +def _is_gen_ai_span(span: Any, existing_attributes: Mapping[str, object]) -> bool: + operation_name = existing_attributes.get(GEN_AI_OPERATION_NAME_KEY) + if operation_name: + return operation_name in GEN_AI_OPERATION_NAMES + + span_name = getattr(span, "name", None) + if not isinstance(span_name, str): + return False + + return any( + span_name == operation_name or span_name.startswith(f"{operation_name} ") + for operation_name in GEN_AI_OPERATION_NAMES + ) + + # pylint: disable=broad-exception-caught, too-many-branches, useless-parent-delegation # pylint: disable=global-statement class A365SpanProcessor(BaseSpanProcessor): """Span processor that stamps agent identity and propagates baggage to span attributes. - Static identity (tenant_id, agent_id) is set from configuration on every span. + Static identity (tenant_id, agent_id) is set from configuration on qualifying GenAI spans. Additional baggage entries are propagated selectively for documented keys. Never overwrites existing attributes. """ @@ -126,6 +154,9 @@ def on_start(self, span, parent_context=None): # type: ignore[override] except Exception: existing = {} + if not _is_gen_ai_span(span, existing): + return super().on_start(span, parent_context) + if self._tenant_id and TENANT_ID_KEY not in existing: try: span.set_attribute(TENANT_ID_KEY, self._tenant_id) @@ -151,13 +182,7 @@ 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_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 - + is_invoke_agent = _matches_operation_name(span, existing, INVOKE_AGENT_OPERATION_NAME) target_keys = list(COMMON_ATTRIBUTES) if is_invoke_agent: for k in INVOKE_AGENT_ATTRIBUTES: diff --git a/src/microsoft/opentelemetry/a365/core/exporters/utils.py b/src/microsoft/opentelemetry/a365/core/exporters/utils.py index 3cea00bf..d3d269e2 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/utils.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/utils.py @@ -70,8 +70,8 @@ OUTPUT_MESSAGES_OPERATION_NAME, CHAT_OPERATION_NAME, APPLY_GUARDRAIL_OPERATION_NAME, - InferenceOperationType.CHAT.value, } + | {operation.value for operation in InferenceOperationType} ) @@ -458,7 +458,7 @@ def _create_fic_token_resolver(scope_override: Optional[str] = None) -> Callable - ``A365_AGENTIC_USER_ID`` """ try: - import msal + import msal # type: ignore[import-untyped] except ImportError: logger.warning( "msal is not installed. Install it (`pip install msal`) to use FIC token authentication for A365 export." diff --git a/tests/a365/test_exporter.py b/tests/a365/test_exporter.py index 18f2f913..1c5b6b50 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,25 @@ 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_every_inference_operation_type(self): + """Every InferenceOperationType value is kept without normalization.""" 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" - ) - result = exporter.export([text_completion_span, generate_content_span]) + spans = [ + _make_span( + name=f"{operation.value}_span", + trace_id=index + 3, + span_id=index + 4, + operation_name=operation.value, + ) + for index, operation in enumerate(InferenceOperationType) + ] + + 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..1f048639 100644 --- a/tests/a365/test_span_processor.py +++ b/tests/a365/test_span_processor.py @@ -7,12 +7,34 @@ from opentelemetry import baggage, context +from microsoft.opentelemetry.a365.constants import ( + APPLY_GUARDRAIL_OPERATION_NAME, + CHAT_OPERATION_NAME, + EXECUTE_TOOL_OPERATION_NAME, + GEN_AI_OPERATION_NAME_KEY, + INVOKE_AGENT_OPERATION_NAME, + OUTPUT_MESSAGES_OPERATION_NAME, +) +from microsoft.opentelemetry.a365.core.inference_operation_type import InferenceOperationType from microsoft.opentelemetry.a365.core.exporters.span_processor import ( A365SpanProcessor, COMMON_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES, ) +RECOGNIZED_OPERATION_NAMES = tuple( + dict.fromkeys( + [ + 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), + ] + ) +) + class TestA365SpanProcessor(unittest.TestCase): # -- identity auto-stamping from constructor -- @@ -20,24 +42,24 @@ class TestA365SpanProcessor(unittest.TestCase): def test_stamps_tenant_id_from_constructor(self): processor = A365SpanProcessor(tenant_id="cfg-tenant") span = MagicMock() - span.name = "test_span" - span.attributes = {} + span.name = "invoke_agent Test" + span.attributes = {GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME} processor.on_start(span, parent_context=context.get_current()) span.set_attribute.assert_any_call("microsoft.tenant.id", "cfg-tenant") def test_stamps_agent_id_from_constructor(self): processor = A365SpanProcessor(agent_id="cfg-agent") span = MagicMock() - span.name = "test_span" - span.attributes = {} + span.name = "invoke_agent Test" + span.attributes = {GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME} processor.on_start(span, parent_context=context.get_current()) span.set_attribute.assert_any_call("gen_ai.agent.id", "cfg-agent") def test_stamps_both_identity_fields(self): processor = A365SpanProcessor(tenant_id="t1", agent_id="a1") span = MagicMock() - span.name = "test_span" - span.attributes = {} + span.name = "invoke_agent Test" + span.attributes = {GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME} processor.on_start(span, parent_context=context.get_current()) span.set_attribute.assert_any_call("microsoft.tenant.id", "t1") span.set_attribute.assert_any_call("gen_ai.agent.id", "a1") @@ -45,8 +67,12 @@ def test_stamps_both_identity_fields(self): def test_identity_does_not_overwrite_existing(self): processor = A365SpanProcessor(tenant_id="cfg-tenant", agent_id="cfg-agent") span = MagicMock() - span.name = "test_span" - span.attributes = {"microsoft.tenant.id": "existing", "gen_ai.agent.id": "existing"} + span.name = "invoke_agent Test" + span.attributes = { + GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME, + "microsoft.tenant.id": "existing", + "gen_ai.agent.id": "existing", + } processor.on_start(span, parent_context=context.get_current()) for call in span.set_attribute.call_args_list: self.assertNotIn(call[0][0], ("microsoft.tenant.id", "gen_ai.agent.id")) @@ -54,19 +80,35 @@ def test_identity_does_not_overwrite_existing(self): def test_identity_with_empty_baggage(self): processor = A365SpanProcessor(tenant_id="t1", agent_id="a1") span = MagicMock() - span.name = "test_span" - span.attributes = {} + span.name = "invoke_agent Test" + span.attributes = {GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME} processor.on_start(span, parent_context=context.get_current()) self.assertEqual(span.set_attribute.call_count, 2) + def test_recognized_operation_values_receive_identity_and_common_baggage(self): + for operation_name in RECOGNIZED_OPERATION_NAMES: + with self.subTest(operation_name=operation_name): + processor = A365SpanProcessor(tenant_id="cfg-tenant", agent_id="cfg-agent") + span = MagicMock() + span.name = f"{operation_name} Test" + span.attributes = {GEN_AI_OPERATION_NAME_KEY: operation_name} + + ctx = baggage.set_baggage("user.id", "user-1", context.get_current()) + + processor.on_start(span, parent_context=ctx) + + span.set_attribute.assert_any_call("microsoft.tenant.id", "cfg-tenant") + span.set_attribute.assert_any_call("gen_ai.agent.id", "cfg-agent") + span.set_attribute.assert_any_call("user.id", "user-1") + # -- baggage propagation -- def test_propagates_common_baggage(self): processor = A365SpanProcessor() span = MagicMock() - span.name = "test_span" - span.attributes = {} + span.name = "invoke_agent Test" + span.attributes = {GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME} ctx = context.get_current() ctx = baggage.set_baggage("microsoft.tenant.id", "my-tenant", ctx) @@ -81,8 +123,11 @@ def test_does_not_overwrite_existing_attributes(self): processor = A365SpanProcessor() span = MagicMock() - span.name = "test_span" - span.attributes = {"microsoft.tenant.id": "existing-tenant"} + span.name = "invoke_agent Test" + span.attributes = { + GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME, + "microsoft.tenant.id": "existing-tenant", + } ctx = context.get_current() ctx = baggage.set_baggage("microsoft.tenant.id", "baggage-tenant", ctx) @@ -142,12 +187,36 @@ def test_empty_baggage(self): span.set_attribute.assert_not_called() + def test_unrelated_span_receives_no_identity_or_baggage(self): + processor = A365SpanProcessor(tenant_id="tenant", agent_id="agent") + span = MagicMock() + span.name = "http.request" + span.attributes = {} + ctx = baggage.set_baggage("user.id", "user", context.get_current()) + processor.on_start(span, parent_context=ctx) + span.set_attribute.assert_not_called() + + def test_recognized_name_prefix_receives_identity_and_common_baggage_without_operation_attribute(self): + for operation_name in RECOGNIZED_OPERATION_NAMES: + with self.subTest(operation_name=operation_name): + processor = A365SpanProcessor(tenant_id="tenant", agent_id="agent") + span = MagicMock() + span.name = f"{operation_name} Test" + span.attributes = {} + ctx = baggage.set_baggage("user.id", "user", context.get_current()) + + processor.on_start(span, parent_context=ctx) + + span.set_attribute.assert_any_call("microsoft.tenant.id", "tenant") + span.set_attribute.assert_any_call("gen_ai.agent.id", "agent") + span.set_attribute.assert_any_call("user.id", "user") + def test_none_context(self): processor = A365SpanProcessor() span = MagicMock() - span.name = "test_span" - span.attributes = {} + span.name = "invoke_agent Test" + span.attributes = {GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME} # Should not raise processor.on_start(span, parent_context=None) From d700d91e4d7215f55c2ad7d3994929e15bbc8579 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 15:45:37 -0600 Subject: [PATCH 02/10] docs: note GenAI-only baggage processing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da53e9e..52770896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ - Update NOTICE to include the license declarations for external packages flagged in MPL review. ([#255](https://github.com/microsoft/opentelemetry-distro-python/pull/255)) +### Bugs Fixed +- Stop adding A365 baggage and configured identity attributes to unrelated application spans, matching the .NET PR #99 GenAI-only processing behavior. + # 1.3.8 (2026-08-20) ### Features Added - Add support for agent identity propagation for compiled agents in nested graph From c6b30780de7d02d57e782441782f3b7733a651dc Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 15:53:37 -0600 Subject: [PATCH 03/10] Decouple processor classification from exporter filtering Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a365/core/exporters/span_processor.py | 23 ++++++++++++++--- .../a365/core/exporters/utils.py | 4 +-- tests/a365/test_exporter.py | 25 ++++++++----------- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py index 9408a17f..a75a29d0 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py @@ -46,6 +46,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, @@ -57,10 +61,23 @@ 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 # mypy: disable-error-code="no-untyped-def" +# Processor-only operation names used to classify qualifying GenAI spans. +GEN_AI_RECOGNIZED_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} +) + + # Generic / common tracing attributes propagated from baggage to qualifying GenAI spans COMMON_ATTRIBUTES = [ TENANT_ID_KEY, @@ -114,7 +131,7 @@ def _matches_operation_name(span: Any, existing_attributes: Mapping[str, object] def _is_gen_ai_span(span: Any, existing_attributes: Mapping[str, object]) -> bool: operation_name = existing_attributes.get(GEN_AI_OPERATION_NAME_KEY) if operation_name: - return operation_name in GEN_AI_OPERATION_NAMES + return operation_name in GEN_AI_RECOGNIZED_OPERATION_NAMES span_name = getattr(span, "name", None) if not isinstance(span_name, str): @@ -122,7 +139,7 @@ def _is_gen_ai_span(span: Any, existing_attributes: Mapping[str, object]) -> boo return any( span_name == operation_name or span_name.startswith(f"{operation_name} ") - for operation_name in GEN_AI_OPERATION_NAMES + for operation_name in GEN_AI_RECOGNIZED_OPERATION_NAMES ) diff --git a/src/microsoft/opentelemetry/a365/core/exporters/utils.py b/src/microsoft/opentelemetry/a365/core/exporters/utils.py index d3d269e2..3cea00bf 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/utils.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/utils.py @@ -70,8 +70,8 @@ OUTPUT_MESSAGES_OPERATION_NAME, CHAT_OPERATION_NAME, APPLY_GUARDRAIL_OPERATION_NAME, + InferenceOperationType.CHAT.value, } - | {operation.value for operation in InferenceOperationType} ) @@ -458,7 +458,7 @@ def _create_fic_token_resolver(scope_override: Optional[str] = None) -> Callable - ``A365_AGENTIC_USER_ID`` """ try: - import msal # type: ignore[import-untyped] + import msal except ImportError: logger.warning( "msal is not installed. Install it (`pip install msal`) to use FIC token authentication for A365 export." diff --git a/tests/a365/test_exporter.py b/tests/a365/test_exporter.py index 1c5b6b50..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,25 +604,21 @@ def test_export_includes_inference_operation_type_chat_spans(self): exporter.shutdown() @patch.dict(os.environ, {}, clear=True) - def test_export_includes_every_inference_operation_type(self): - """Every InferenceOperationType value is kept without normalization.""" + 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()) + 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.value}_span", - trace_id=index + 3, - span_id=index + 4, - operation_name=operation.value, - ) - for index, operation in enumerate(InferenceOperationType) - ] - - 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 743112000788377c074aff52101a5077bd7f4eee Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 17:48:06 -0600 Subject: [PATCH 04/10] Fix A365 baggage GenAI classification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- A365_DOCUMENTATION.md | 2 +- CHANGELOG.md | 7 +- samples/langchain/validate_traces.py | 4 +- .../sample_maf_agent.py | 2 +- src/microsoft/opentelemetry/a365/README.md | 2 +- .../opentelemetry/a365/core/constants.py | 13 +++ .../a365/core/exporters/span_processor.py | 82 +++++++++---------- .../a365/core/exporters/utils.py | 2 + tests/a365/test_span_processor.py | 44 ++++++++++ 9 files changed, 105 insertions(+), 53 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 17aa49ac..b55ca3cc 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -231,7 +231,7 @@ ObservabilityHostingManager.configure( ## Baggage -Baggage sets per-request context (tenant, agent, user) that flows to all spans. **Without `tenant_id` and `agent_id`, the exporter silently drops spans.** +Baggage sets per-request context (tenant, agent, user) that flows to recognized GenAI spans only. **Without `tenant_id` and `agent_id`, the exporter silently drops spans.** ### BaggageBuilder diff --git a/CHANGELOG.md b/CHANGELOG.md index 52770896..377b9a81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ # Release History +# Unreleased +### Bugs Fixed +- Stop adding A365 baggage and configured identity attributes to unrelated application spans, matching the .NET PR #99 GenAI-only processing behavior. + # 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`. @@ -8,9 +12,6 @@ - Update NOTICE to include the license declarations for external packages flagged in MPL review. ([#255](https://github.com/microsoft/opentelemetry-distro-python/pull/255)) -### Bugs Fixed -- Stop adding A365 baggage and configured identity attributes to unrelated application spans, matching the .NET PR #99 GenAI-only processing behavior. - # 1.3.8 (2026-08-20) ### Features Added - Add support for agent identity propagation for compiled agents in nested graph 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..1bed6995 100644 --- a/src/microsoft/opentelemetry/a365/README.md +++ b/src/microsoft/opentelemetry/a365/README.md @@ -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 OpenTelemetry baggage entries onto recognized GenAI spans only, with special handling for invoke_agent 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. | diff --git a/src/microsoft/opentelemetry/a365/core/constants.py b/src/microsoft/opentelemetry/a365/core/constants.py index 6644ed03..e8d6a41d 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,17 @@ 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} +) + # --- 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 a75a29d0..c33c423e 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py @@ -22,7 +22,7 @@ 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_PARENT_SPAN_ID_KEY, @@ -46,10 +46,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, @@ -61,22 +58,9 @@ USER_ID_KEY, USER_NAME_KEY, ) -from microsoft.opentelemetry.a365.core.inference_operation_type import InferenceOperationType # mypy: disable-error-code="no-untyped-def" -# Processor-only operation names used to classify qualifying GenAI spans. -GEN_AI_RECOGNIZED_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} -) - # Generic / common tracing attributes propagated from baggage to qualifying GenAI spans COMMON_ATTRIBUTES = [ @@ -119,28 +103,38 @@ ] -def _matches_operation_name(span: Any, existing_attributes: Mapping[str, object], operation_name: str) -> bool: - existing_operation_name = existing_attributes.get(GEN_AI_OPERATION_NAME_KEY) - if existing_operation_name: - return existing_operation_name == operation_name +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: span_name = getattr(span, "name", None) - return isinstance(span_name, str) and (span_name == operation_name or span_name.startswith(f"{operation_name} ")) + 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 _is_gen_ai_span(span: Any, existing_attributes: Mapping[str, object]) -> bool: - operation_name = existing_attributes.get(GEN_AI_OPERATION_NAME_KEY) - if operation_name: - return operation_name in GEN_AI_RECOGNIZED_OPERATION_NAMES - span_name = getattr(span, "name", None) - if not isinstance(span_name, str): - return False +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)) - return any( - span_name == operation_name or span_name.startswith(f"{operation_name} ") - for operation_name in GEN_AI_RECOGNIZED_OPERATION_NAMES - ) + 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 # pylint: disable=broad-exception-caught, too-many-branches, useless-parent-delegation @@ -171,7 +165,15 @@ def on_start(self, span, parent_context=None): # type: ignore[override] except Exception: existing = {} - if not _is_gen_ai_span(span, existing): + if ctx is None: + baggage_map = {} + else: + try: + baggage_map = baggage.get_all(ctx) or {} + except Exception: + baggage_map = {} + + if not _is_gen_ai_span(span, existing, baggage_map): return super().on_start(span, parent_context) if self._tenant_id and TENANT_ID_KEY not in existing: @@ -191,15 +193,7 @@ def on_start(self, span, parent_context=None): # type: ignore[override] except Exception: existing = {} - if ctx is None: - return super().on_start(span, parent_context) - - try: - baggage_map = baggage.get_all(ctx) or {} - except Exception: - baggage_map = {} - - is_invoke_agent = _matches_operation_name(span, existing, INVOKE_AGENT_OPERATION_NAME) + is_invoke_agent = _classify_gen_ai_operation(span, existing, baggage_map) == INVOKE_AGENT_OPERATION_NAME target_keys = list(COMMON_ATTRIBUTES) if is_invoke_agent: for k in INVOKE_AGENT_ATTRIBUTES: diff --git a/src/microsoft/opentelemetry/a365/core/exporters/utils.py b/src/microsoft/opentelemetry/a365/core/exporters/utils.py index 3cea00bf..76cdc92c 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/utils.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/utils.py @@ -63,6 +63,8 @@ # Operation names that identify a span as eligible for export to the Agent 365 # observability ingest service. Only spans whose gen_ai.operation.name matches # one of these values are included; all other spans are filtered out. +# Deliberately narrower than the processor recognition set: baggage enrichment +# may run on additional GenAI spans that are not exported to A365 ingest. GEN_AI_OPERATION_NAMES: frozenset[str] = frozenset( { INVOKE_AGENT_OPERATION_NAME, diff --git a/tests/a365/test_span_processor.py b/tests/a365/test_span_processor.py index 1f048639..2337ee2e 100644 --- a/tests/a365/test_span_processor.py +++ b/tests/a365/test_span_processor.py @@ -15,6 +15,7 @@ INVOKE_AGENT_OPERATION_NAME, OUTPUT_MESSAGES_OPERATION_NAME, ) +from microsoft.opentelemetry.a365.core.constants import GEN_AI_PROCESSOR_OPERATION_NAMES from microsoft.opentelemetry.a365.core.inference_operation_type import InferenceOperationType from microsoft.opentelemetry.a365.core.exporters.span_processor import ( A365SpanProcessor, @@ -39,6 +40,17 @@ class TestA365SpanProcessor(unittest.TestCase): # -- identity auto-stamping from constructor -- + def test_processor_operation_names_include_a365_and_inference_operations(self): + expected = { + 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), + } + self.assertEqual(GEN_AI_PROCESSOR_OPERATION_NAMES, expected) + def test_stamps_tenant_id_from_constructor(self): processor = A365SpanProcessor(tenant_id="cfg-tenant") span = MagicMock() @@ -211,6 +223,38 @@ def test_recognized_name_prefix_receives_identity_and_common_baggage_without_ope span.set_attribute.assert_any_call("gen_ai.agent.id", "agent") span.set_attribute.assert_any_call("user.id", "user") + def test_recognized_baggage_operation_receives_identity_and_common_baggage_without_operation_or_name(self): + processor = A365SpanProcessor() + span = MagicMock() + span.name = "library.span" + span.attributes = {} + + ctx = context.get_current() + ctx = baggage.set_baggage(GEN_AI_OPERATION_NAME_KEY, INVOKE_AGENT_OPERATION_NAME, ctx) + ctx = baggage.set_baggage("microsoft.tenant.id", "tenant", ctx) + ctx = baggage.set_baggage("gen_ai.agent.id", "agent", ctx) + ctx = baggage.set_baggage("user.id", "user", ctx) + + processor.on_start(span, parent_context=ctx) + + span.set_attribute.assert_any_call(GEN_AI_OPERATION_NAME_KEY, INVOKE_AGENT_OPERATION_NAME) + span.set_attribute.assert_any_call("microsoft.tenant.id", "tenant") + span.set_attribute.assert_any_call("gen_ai.agent.id", "agent") + span.set_attribute.assert_any_call("user.id", "user") + + def test_unrecognized_explicit_operation_attribute_does_not_fall_through_to_baggage_or_name(self): + processor = A365SpanProcessor(tenant_id="tenant", agent_id="agent") + span = MagicMock() + span.name = "invoke_agent Test" + span.attributes = {GEN_AI_OPERATION_NAME_KEY: "not_gen_ai"} + + ctx = baggage.set_baggage(GEN_AI_OPERATION_NAME_KEY, INVOKE_AGENT_OPERATION_NAME, context.get_current()) + ctx = baggage.set_baggage("user.id", "user", ctx) + + processor.on_start(span, parent_context=ctx) + + span.set_attribute.assert_not_called() + def test_none_context(self): processor = A365SpanProcessor() From b4423fb36fe1d3336621dce8903c15c93f879597 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 18:51:40 -0600 Subject: [PATCH 05/10] Recognize GenAI instrumentation scopes at span start A365SpanProcessor is registered when the TracerProvider is built, before the platform instrumentors attach their own processors, so its on_start hook runs before LangChain, Semantic Kernel, Agent Framework, and OpenAI Agents apply gen_ai.operation.name. LangChain chat spans start life named "ChatOpenAI" and Semantic Kernel ones as "chat.completions ", so the operation-attribute/baggage/span-name classifier skipped them and the spans lost tenant and agent identity, which made the exporter drop them. Add two span-start signals that are already available on a ReadWriteSpan: known pre-rename span names and the instrumentation scope (source) names of the supported GenAI instrumentations. Explicit operation attributes, recognized operation baggage, and precise span names keep their existing precedence and remain the only signals that classify which operation a span represents. The exporter allowlist is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- A365_DOCUMENTATION.md | 2 + CHANGELOG.md | 1 + src/microsoft/opentelemetry/a365/README.md | 2 +- .../opentelemetry/a365/core/constants.py | 41 +++ .../a365/core/exporters/span_processor.py | 71 ++++- .../a365/core/exporters/utils.py | 3 +- tests/a365/test_span_processor.py | 266 +++++++++++++++++- 7 files changed, 377 insertions(+), 9 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index b55ca3cc..85a5409a 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -233,6 +233,8 @@ ObservabilityHostingManager.configure( Baggage sets per-request context (tenant, agent, user) that flows to recognized GenAI spans only. **Without `tenant_id` and `agent_id`, the exporter silently drops spans.** +A span is recognized as GenAI at span start when any of these hold: it carries a supported `gen_ai.operation.name` attribute, a recognized `gen_ai.operation.name` baggage entry, a span name matching a supported operation (`invoke_agent ...`, `chat ...`, ...) or a known pre-rename name (`chat.completions ...`), or it originates from a supported GenAI instrumentation scope (`Agent365Sdk`, `semantic_kernel.*`, `agent_framework`, `microsoft.opentelemetry._genai.*`, `opentelemetry.instrumentation.openai_v2`, `opentelemetry.instrumentation.openai_agents`). The scope signal matters because LangChain, Semantic Kernel, Agent Framework, and OpenAI Agents all set `gen_ai.operation.name` *after* the span starts — a LangChain chat span begins life named `ChatOpenAI`, and a Semantic Kernel one as `chat.completions gpt-4o`. + ### BaggageBuilder ```python diff --git a/CHANGELOG.md b/CHANGELOG.md index 377b9a81..1a18ccf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ # Unreleased ### Bugs Fixed - Stop adding A365 baggage and configured identity attributes to unrelated application spans, matching the .NET PR #99 GenAI-only processing behavior. +- Recognize supported GenAI instrumentation scopes and pre-rename span names at span start so LangChain (`ChatOpenAI`) and Semantic Kernel (`chat.completions `) spans keep their identity and baggage attributes and are no longer dropped by the exporter. # 1.3.9 (2026-09-09) ### Features Added diff --git a/src/microsoft/opentelemetry/a365/README.md b/src/microsoft/opentelemetry/a365/README.md index 1bed6995..bcf4ae8a 100644 --- a/src/microsoft/opentelemetry/a365/README.md +++ b/src/microsoft/opentelemetry/a365/README.md @@ -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 recognized GenAI spans only, with special handling for invoke_agent spans. | +| `span_processor.py` | `A365SpanProcessor` — propagates OpenTelemetry baggage entries onto recognized GenAI spans only (operation attribute, operation baggage, span name, or supported GenAI instrumentation scope), with special handling for invoke_agent 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. | diff --git a/src/microsoft/opentelemetry/a365/core/constants.py b/src/microsoft/opentelemetry/a365/core/constants.py index e8d6a41d..06b7ca8b 100644 --- a/src/microsoft/opentelemetry/a365/core/constants.py +++ b/src/microsoft/opentelemetry/a365/core/constants.py @@ -34,6 +34,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 c33c423e..8be4e88e 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py @@ -12,6 +12,25 @@ * 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 + +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. + +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``. Only signals 1-3 identify *which* operation a span +represents, which is what gates the invoke_agent-only attributes. """ from __future__ import annotations @@ -45,6 +64,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, @@ -107,9 +128,14 @@ 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 +144,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)) @@ -134,7 +192,14 @@ def _classify_gen_ai_operation( 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 + if _classify_gen_ai_operation(span, existing_attributes, baggage_map) is not None: + return True + + if GEN_AI_OPERATION_NAME_KEY in existing_attributes: + # The span declared an operation this processor does not handle. + return False + + return _has_known_initial_span_name(span) or _is_supported_gen_ai_scope(span) # pylint: disable=broad-exception-caught, too-many-branches, useless-parent-delegation diff --git a/src/microsoft/opentelemetry/a365/core/exporters/utils.py b/src/microsoft/opentelemetry/a365/core/exporters/utils.py index 76cdc92c..5b049ec2 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/utils.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/utils.py @@ -64,7 +64,8 @@ # observability ingest service. Only spans whose gen_ai.operation.name matches # one of these values are included; all other spans are filtered out. # Deliberately narrower than the processor recognition set: baggage enrichment -# may run on additional GenAI spans that are not exported to A365 ingest. +# may run on additional GenAI spans (recognized by instrumentation scope or +# pre-rename span name) that are not exported to A365 ingest. GEN_AI_OPERATION_NAMES: frozenset[str] = frozenset( { INVOKE_AGENT_OPERATION_NAME, diff --git a/tests/a365/test_span_processor.py b/tests/a365/test_span_processor.py index 2337ee2e..4eaf033e 100644 --- a/tests/a365/test_span_processor.py +++ b/tests/a365/test_span_processor.py @@ -3,9 +3,11 @@ # pylint: disable=no-member import unittest +from types import SimpleNamespace from unittest.mock import MagicMock from opentelemetry import baggage, context +from opentelemetry.sdk.trace import TracerProvider from microsoft.opentelemetry.a365.constants import ( APPLY_GUARDRAIL_OPERATION_NAME, @@ -15,7 +17,12 @@ INVOKE_AGENT_OPERATION_NAME, OUTPUT_MESSAGES_OPERATION_NAME, ) -from microsoft.opentelemetry.a365.core.constants import GEN_AI_PROCESSOR_OPERATION_NAMES +from microsoft.opentelemetry.a365.core.constants import ( + GEN_AI_INITIAL_SPAN_NAMES, + GEN_AI_INSTRUMENTATION_SCOPE_ROOTS, + GEN_AI_PROCESSOR_OPERATION_NAMES, + SOURCE_NAME, +) from microsoft.opentelemetry.a365.core.inference_operation_type import InferenceOperationType from microsoft.opentelemetry.a365.core.exporters.span_processor import ( A365SpanProcessor, @@ -23,6 +30,21 @@ INVOKE_AGENT_ATTRIBUTES, ) +LANGCHAIN_SCOPE = "microsoft.opentelemetry._genai._langchain._tracer_instrumentor" +OPENAI_AGENTS_SCOPE = "microsoft.opentelemetry._genai._openai_agents._trace_instrumentor" +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 + + RECOGNIZED_OPERATION_NAMES = tuple( dict.fromkeys( [ @@ -244,9 +266,7 @@ def test_recognized_baggage_operation_receives_identity_and_common_baggage_witho def test_unrecognized_explicit_operation_attribute_does_not_fall_through_to_baggage_or_name(self): processor = A365SpanProcessor(tenant_id="tenant", agent_id="agent") - span = MagicMock() - span.name = "invoke_agent Test" - span.attributes = {GEN_AI_OPERATION_NAME_KEY: "not_gen_ai"} + span = _mock_span("invoke_agent Test", {GEN_AI_OPERATION_NAME_KEY: "not_gen_ai"}) ctx = baggage.set_baggage(GEN_AI_OPERATION_NAME_KEY, INVOKE_AGENT_OPERATION_NAME, context.get_current()) ctx = baggage.set_baggage("user.id", "user", ctx) @@ -282,5 +302,243 @@ def test_invoke_agent_attributes_list(self): self.assertIn("server.port", INVOKE_AGENT_ATTRIBUTES) +class TestA365SpanProcessorGenAiInstrumentationSignals(unittest.TestCase): + """Spans that only become identifiable as GenAI *after* ``on_start``. + + LangChain and Semantic Kernel set ``gen_ai.operation.name`` (and rename the + span) once the call completes, so ``on_start`` sees only the raw span name. + The instrumentation scope is the signal that is already available. + """ + + def _baggage_context(self): + ctx = baggage.set_baggage("microsoft.tenant.id", "baggage-tenant", context.get_current()) + ctx = baggage.set_baggage("gen_ai.agent.id", "baggage-agent", ctx) + ctx = baggage.set_baggage("microsoft.session.id", "session-1", ctx) + ctx = baggage.set_baggage("user.id", "user-1", ctx) + return ctx + + def _assert_enriched(self, span): + span.set_attribute.assert_any_call("microsoft.tenant.id", "baggage-tenant") + span.set_attribute.assert_any_call("gen_ai.agent.id", "baggage-agent") + span.set_attribute.assert_any_call("microsoft.session.id", "session-1") + span.set_attribute.assert_any_call("user.id", "user-1") + + # -- scope constants -- + + def test_scope_roots_cover_supported_gen_ai_instrumentations(self): + self.assertIn(SOURCE_NAME, GEN_AI_INSTRUMENTATION_SCOPE_ROOTS) + self.assertIn("semantic_kernel", GEN_AI_INSTRUMENTATION_SCOPE_ROOTS) + self.assertIn("agent_framework", GEN_AI_INSTRUMENTATION_SCOPE_ROOTS) + self.assertIn("microsoft.opentelemetry._genai", GEN_AI_INSTRUMENTATION_SCOPE_ROOTS) + self.assertIn("opentelemetry.instrumentation.openai_v2", GEN_AI_INSTRUMENTATION_SCOPE_ROOTS) + self.assertIn("opentelemetry.instrumentation.openai_agents", GEN_AI_INSTRUMENTATION_SCOPE_ROOTS) + + def test_initial_span_names_cover_semantic_kernel_completions(self): + self.assertIn("chat.completions", GEN_AI_INITIAL_SPAN_NAMES) + self.assertIn("chat.streaming_completions", GEN_AI_INITIAL_SPAN_NAMES) + self.assertIn("text.completions", GEN_AI_INITIAL_SPAN_NAMES) + self.assertIn("text_completions", GEN_AI_INITIAL_SPAN_NAMES) + + # -- positive cases -- + + def test_langchain_chat_model_span_is_enriched(self): + """The initial LangChain chat-model span is named after the model class.""" + processor = A365SpanProcessor() + span = _mock_span("ChatOpenAI", scope_name=LANGCHAIN_SCOPE) + + processor.on_start(span, parent_context=self._baggage_context()) + + self._assert_enriched(span) + + def test_langchain_chain_span_is_enriched(self): + processor = A365SpanProcessor() + span = _mock_span("RunnableSequence", scope_name=LANGCHAIN_SCOPE) + + processor.on_start(span, parent_context=self._baggage_context()) + + self._assert_enriched(span) + + def test_openai_agents_workflow_span_is_enriched(self): + processor = A365SpanProcessor() + span = _mock_span("Agent workflow", scope_name=OPENAI_AGENTS_SCOPE) + + processor.on_start(span, parent_context=self._baggage_context()) + + self._assert_enriched(span) + + def test_semantic_kernel_chat_completions_span_is_enriched(self): + """Semantic Kernel starts chat spans as ``chat.completions ``.""" + processor = A365SpanProcessor() + span = _mock_span("chat.completions gpt-4o", scope_name=SEMANTIC_KERNEL_SCOPE) + + processor.on_start(span, parent_context=self._baggage_context()) + + self._assert_enriched(span) + + def test_semantic_kernel_chat_completions_span_enriched_without_scope(self): + """The known initial span name alone is enough, independent of scope.""" + processor = A365SpanProcessor() + span = _mock_span("chat.completions gpt-4o") + + processor.on_start(span, parent_context=self._baggage_context()) + + self._assert_enriched(span) + + def test_semantic_kernel_streaming_completions_span_is_enriched(self): + processor = A365SpanProcessor() + span = _mock_span("chat.streaming_completions gpt-4o", scope_name=SEMANTIC_KERNEL_SCOPE) + + processor.on_start(span, parent_context=self._baggage_context()) + + self._assert_enriched(span) + + def test_semantic_kernel_auto_function_invocation_span_is_enriched(self): + processor = A365SpanProcessor() + span = _mock_span("AutoFunctionInvocationLoop", scope_name="semantic_kernel.connectors.ai") + + processor.on_start(span, parent_context=self._baggage_context()) + + self._assert_enriched(span) + + def test_agent_framework_scope_root_matches_exactly(self): + processor = A365SpanProcessor() + span = _mock_span("embeddings text-embedding-3-small", scope_name=AGENT_FRAMEWORK_SCOPE) + + processor.on_start(span, parent_context=self._baggage_context()) + + self._assert_enriched(span) + + def test_a365_source_name_scope_is_enriched(self): + processor = A365SpanProcessor() + span = _mock_span("custom-a365-span", scope_name=SOURCE_NAME) + + processor.on_start(span, parent_context=self._baggage_context()) + + self._assert_enriched(span) + + def test_scope_signal_does_not_mark_span_as_invoke_agent(self): + """Scope-only recognition must not leak invoke_agent-only attributes.""" + processor = A365SpanProcessor() + span = _mock_span("ChatOpenAI", scope_name=LANGCHAIN_SCOPE) + + ctx = baggage.set_baggage("microsoft.a365.caller.agent.id", "caller-1", self._baggage_context()) + + 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") + + # -- negative cases -- + + def test_unrelated_scope_with_nearby_span_name_is_untouched(self): + for span_name in ("chat.completions.retry", "chatty service", "text_completionsX", "invoke_agentic"): + with self.subTest(span_name=span_name): + processor = A365SpanProcessor(tenant_id="tenant", agent_id="agent") + span = _mock_span(span_name, scope_name="opentelemetry.instrumentation.requests") + + processor.on_start(span, parent_context=self._baggage_context()) + + span.set_attribute.assert_not_called() + + def test_nearby_scope_names_are_untouched(self): + for scope_name in ( + "semantic_kernel_helpers", + "agent_framework_extras", + "my.semantic_kernel", + "microsoft.opentelemetry._genai_extras", + "opentelemetry.instrumentation.openai_v2_extras", + "Agent365SdkExtras", + ): + with self.subTest(scope_name=scope_name): + processor = A365SpanProcessor(tenant_id="tenant", agent_id="agent") + span = _mock_span("SomeOperation", scope_name=scope_name) + + processor.on_start(span, parent_context=self._baggage_context()) + + span.set_attribute.assert_not_called() + + def test_common_http_span_is_untouched(self): + processor = A365SpanProcessor(tenant_id="tenant", agent_id="agent") + span = _mock_span("GET /api/orders", scope_name="opentelemetry.instrumentation.requests") + + processor.on_start(span, parent_context=self._baggage_context()) + + span.set_attribute.assert_not_called() + + def test_missing_instrumentation_scope_is_tolerated(self): + processor = A365SpanProcessor(tenant_id="tenant", agent_id="agent") + span = _mock_span("GET /api/orders") + del span.instrumentation_scope + + processor.on_start(span, parent_context=self._baggage_context()) + + span.set_attribute.assert_not_called() + + +class TestA365SpanProcessorWithTracerProvider(unittest.TestCase): + """End-to-end checks against a real SDK ``TracerProvider``. + + Mirrors the distro's registration order: ``A365SpanProcessor`` is attached + when the provider is built, platform processors are attached later by the + instrumentors. ``A365SpanProcessor.on_start`` therefore runs *before* the + Semantic Kernel processor renames the span and sets its operation name. + """ + + def setUp(self): + self.provider = TracerProvider() + self.provider.add_span_processor(A365SpanProcessor()) + + from microsoft.opentelemetry._semantic_kernel._span_processor import SemanticKernelSpanProcessor + + self.provider.add_span_processor(SemanticKernelSpanProcessor()) + + ctx = baggage.set_baggage("microsoft.tenant.id", "tenant-1", context.get_current()) + ctx = baggage.set_baggage("gen_ai.agent.id", "agent-1", ctx) + ctx = baggage.set_baggage("microsoft.session.id", "session-1", ctx) + ctx = baggage.set_baggage("user.id", "user-1", ctx) + self._token = context.attach(ctx) + + def tearDown(self): + context.detach(self._token) + self.provider.shutdown() + + def _start_span(self, scope_name, span_name): + tracer = self.provider.get_tracer(scope_name) + span = tracer.start_span(span_name) + span.end() + return span + + def test_langchain_chat_model_span_keeps_identity(self): + span = self._start_span(LANGCHAIN_SCOPE, "ChatOpenAI") + + self.assertEqual(span.attributes.get("microsoft.tenant.id"), "tenant-1") + self.assertEqual(span.attributes.get("gen_ai.agent.id"), "agent-1") + self.assertEqual(span.attributes.get("microsoft.session.id"), "session-1") + self.assertEqual(span.attributes.get("user.id"), "user-1") + + def test_semantic_kernel_chat_completions_span_keeps_identity(self): + span = self._start_span(SEMANTIC_KERNEL_SCOPE, "chat.completions gpt-4o") + + # The Semantic Kernel processor runs after A365 and renames the span. + self.assertEqual(span.name, "chat gpt-4o") + self.assertEqual(span.attributes.get("gen_ai.operation.name"), "chat") + self.assertEqual(span.attributes.get("microsoft.tenant.id"), "tenant-1") + self.assertEqual(span.attributes.get("gen_ai.agent.id"), "agent-1") + self.assertEqual(span.attributes.get("user.id"), "user-1") + + def test_invoke_agent_span_keeps_identity(self): + span = self._start_span(LANGCHAIN_SCOPE, "invoke_agent Travel_Assistant") + + self.assertEqual(span.attributes.get("microsoft.tenant.id"), "tenant-1") + self.assertEqual(span.attributes.get("gen_ai.agent.id"), "agent-1") + + def test_unrelated_http_span_is_untouched(self): + span = self._start_span("opentelemetry.instrumentation.requests", "GET") + + self.assertIsNone((span.attributes or {}).get("microsoft.tenant.id")) + self.assertIsNone((span.attributes or {}).get("gen_ai.agent.id")) + self.assertIsNone((span.attributes or {}).get("user.id")) + + if __name__ == "__main__": unittest.main() From a1fc42f1c4aac23747e074456726587e4a2fe2be Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Fri, 11 Sep 2026 19:34:03 -0600 Subject: [PATCH 06/10] fix: keep GenAI spans with unmodeled operation names An explicit but unrecognized gen_ai.operation.name attribute classified a span as non-GenAI before the supported instrumentation-scope signal was evaluated, dropping 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 invoke_agent-only attributes are withheld. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- A365_DOCUMENTATION.md | 4 +- CHANGELOG.md | 1 + .../a365/core/exporters/span_processor.py | 81 +++++++------ tests/a365/test_span_processor.py | 107 ++++++++++++++++++ 4 files changed, 160 insertions(+), 33 deletions(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index 85a5409a..e20082a3 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -233,7 +233,9 @@ ObservabilityHostingManager.configure( Baggage sets per-request context (tenant, agent, user) that flows to recognized GenAI spans only. **Without `tenant_id` and `agent_id`, the exporter silently drops spans.** -A span is recognized as GenAI at span start when any of these hold: it carries a supported `gen_ai.operation.name` attribute, a recognized `gen_ai.operation.name` baggage entry, a span name matching a supported operation (`invoke_agent ...`, `chat ...`, ...) or a known pre-rename name (`chat.completions ...`), or it originates from a supported GenAI instrumentation scope (`Agent365Sdk`, `semantic_kernel.*`, `agent_framework`, `microsoft.opentelemetry._genai.*`, `opentelemetry.instrumentation.openai_v2`, `opentelemetry.instrumentation.openai_agents`). The scope signal matters because LangChain, Semantic Kernel, Agent Framework, and OpenAI Agents all set `gen_ai.operation.name` *after* the span starts — a LangChain chat span begins life named `ChatOpenAI`, and a Semantic Kernel one as `chat.completions gpt-4o`. +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`). The scope signal matters because LangChain, Semantic Kernel, Agent Framework, and OpenAI Agents all set `gen_ai.operation.name` *after* the span starts — a LangChain chat span begins life named `ChatOpenAI`, and a Semantic Kernel one as `chat.completions gpt-4o`. + +Spans classified only by instrumentation scope are GenAI with an unknown operation: they receive the common baggage attributes, but never the `invoke_agent`-only ones (caller agent details, `server.address`, `server.port`). ### BaggageBuilder diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a18ccf8..d4f4cd4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Bugs Fixed - Stop adding A365 baggage and configured identity attributes to unrelated application spans, matching the .NET PR #99 GenAI-only processing behavior. - Recognize supported GenAI instrumentation scopes and pre-rename span names at span start so LangChain (`ChatOpenAI`) and Semantic Kernel (`chat.completions `) spans keep their identity and baggage attributes and are no longer dropped by the exporter. +- Keep GenAI spans that declare an operation the processor does not model (`chain`, `embeddings`, `text_completion`, `generate_content`, `create_agent`) when a supported instrumentation scope emitted them, instead of treating an unrecognized `gen_ai.operation.name` attribute as non-GenAI. 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 8be4e88e..4238df74 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py @@ -13,29 +13,36 @@ attribute, add it via span.set_attribute * Never overwrites existing attributes -A span is recognized as GenAI when any of the following signals fires at +A span is 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. + 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``. Only signals 1-3 identify *which* operation a span -represents, which is what gates the invoke_agent-only attributes. +``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 from collections.abc import Mapping +from dataclasses import dataclass from typing import Any from opentelemetry import baggage, context @@ -124,6 +131,14 @@ ] +@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 @@ -175,31 +190,33 @@ 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( +def _classify_gen_ai_span( 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.""" +) -> _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: - 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 + 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)) - 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: - if _classify_gen_ai_operation(span, existing_attributes, baggage_map) is not None: - return True - - if GEN_AI_OPERATION_NAME_KEY in existing_attributes: - # The span declared an operation this processor does not handle. - return False + 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 _has_known_initial_span_name(span) or _is_supported_gen_ai_scope(span) + return _GenAISpanClassification(_has_known_initial_span_name(span) or _is_supported_gen_ai_scope(span)) # pylint: disable=broad-exception-caught, too-many-branches, useless-parent-delegation @@ -238,7 +255,8 @@ def on_start(self, span, parent_context=None): # type: ignore[override] except Exception: baggage_map = {} - if not _is_gen_ai_span(span, existing, baggage_map): + classification = _classify_gen_ai_span(span, existing, baggage_map) + if not classification.is_gen_ai_span: return super().on_start(span, parent_context) if self._tenant_id and TENANT_ID_KEY not in existing: @@ -258,9 +276,8 @@ def on_start(self, span, parent_context=None): # type: ignore[override] except Exception: existing = {} - is_invoke_agent = _classify_gen_ai_operation(span, existing, baggage_map) == INVOKE_AGENT_OPERATION_NAME 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) diff --git a/tests/a365/test_span_processor.py b/tests/a365/test_span_processor.py index 4eaf033e..cf884b71 100644 --- a/tests/a365/test_span_processor.py +++ b/tests/a365/test_span_processor.py @@ -32,6 +32,8 @@ 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" @@ -428,6 +430,111 @@ def test_scope_signal_does_not_mark_span_as_invoke_agent(self): for call in span.set_attribute.call_args_list: self.assertNotEqual(call[0][0], "microsoft.a365.caller.agent.id") + # -- explicit but unrecognized operation names -- + + def test_openai_agents_scope_with_explicit_chain_operation_is_enriched(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, + ) + + processor.on_start(span, parent_context=self._baggage_context()) + + self._assert_enriched(span) + + def test_agent_framework_scope_with_explicit_embeddings_operation_is_enriched(self): + processor = A365SpanProcessor() + span = _mock_span( + "embeddings text-embedding-3-small", + {GEN_AI_OPERATION_NAME_KEY: "embeddings"}, + scope_name=AGENT_FRAMEWORK_SCOPE, + ) + + processor.on_start(span, parent_context=self._baggage_context()) + + self._assert_enriched(span) + + def test_openai_v2_scope_with_explicit_text_completion_operation_is_enriched(self): + processor = A365SpanProcessor() + span = _mock_span( + "text_completion gpt-4o", + {GEN_AI_OPERATION_NAME_KEY: "text_completion"}, + scope_name=OPENAI_V2_SCOPE, + ) + + processor.on_start(span, parent_context=self._baggage_context()) + + self._assert_enriched(span) + + def test_supported_scopes_with_other_unrecognized_operations_are_enriched(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, + ) + + processor.on_start(span, parent_context=self._baggage_context()) + + self._assert_enriched(span) + + def test_unrecognized_explicit_operation_on_unrelated_scope_is_untouched(self): + processor = A365SpanProcessor(tenant_id="tenant", agent_id="agent") + span = _mock_span( + "POST /v1/chain", + {GEN_AI_OPERATION_NAME_KEY: "chain"}, + scope_name="opentelemetry.instrumentation.requests", + ) + + processor.on_start(span, parent_context=self._baggage_context()) + + span.set_attribute.assert_not_called() + + 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, + ) + + ctx = baggage.set_baggage("microsoft.a365.caller.agent.id", "caller-1", self._baggage_context()) + ctx = baggage.set_baggage("server.address", "agent.contoso.com", ctx) + + processor.on_start(span, parent_context=ctx) + + self._assert_enriched(span) + 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_OPERATION_NAME, self._baggage_context()) + ctx = baggage.set_baggage("microsoft.a365.caller.agent.id", "caller-1", ctx) + + processor.on_start(span, parent_context=ctx) + + self._assert_enriched(span) + for call in span.set_attribute.call_args_list: + self.assertNotEqual(call[0][0], "microsoft.a365.caller.agent.id") + # -- negative cases -- def test_unrelated_scope_with_nearby_span_name_is_untouched(self): From c8ab8604247dc7f238472b36a881347b7dfc90c9 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Tue, 22 Sep 2026 15:25:38 -0600 Subject: [PATCH 07/10] test: cover inherited operation precedence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/a365/test_span_processor.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/a365/test_span_processor.py b/tests/a365/test_span_processor.py index cf884b71..313fcbbf 100644 --- a/tests/a365/test_span_processor.py +++ b/tests/a365/test_span_processor.py @@ -430,6 +430,19 @@ def test_scope_signal_does_not_mark_span_as_invoke_agent(self): for call in span.set_attribute.call_args_list: self.assertNotEqual(call[0][0], "microsoft.a365.caller.agent.id") + def test_span_name_operation_takes_precedence_over_inherited_baggage_operation(self): + processor = A365SpanProcessor() + span = _mock_span("execute_tool get_weather") + + 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) + ctx = baggage.set_baggage("server.address", "agent.contoso.com", ctx) + + processor.on_start(span, parent_context=ctx) + + for call in span.set_attribute.call_args_list: + self.assertNotIn(call[0][0], ("microsoft.a365.caller.agent.id", "server.address")) + # -- explicit but unrecognized operation names -- def test_openai_agents_scope_with_explicit_chain_operation_is_enriched(self): From 9d40d90fab9c591507a61ce39ac72a2699351488 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Wed, 23 Sep 2026 10:04:04 -0600 Subject: [PATCH 08/10] docs: correct GenAI classification precedence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- A365_DOCUMENTATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/A365_DOCUMENTATION.md b/A365_DOCUMENTATION.md index af84c51a..ccf70e36 100644 --- a/A365_DOCUMENTATION.md +++ b/A365_DOCUMENTATION.md @@ -233,7 +233,7 @@ ObservabilityHostingManager.configure( Baggage sets per-request context (tenant, agent, user) that flows to recognized GenAI spans only. **Without `tenant_id` and `agent_id`, the exporter silently drops spans.** -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`). The scope signal matters because LangChain, Semantic Kernel, Agent Framework, and OpenAI Agents all set `gen_ai.operation.name` *after* the span starts — a LangChain chat span begins life named `ChatOpenAI`, and a Semantic Kernel one as `chat.completions gpt-4o`. +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 span name matching a supported operation (`invoke_agent ...`, `chat ...`, ...) or a known pre-rename name (`chat.completions ...`), then a recognized `gen_ai.operation.name` baggage entry, then a supported GenAI instrumentation scope (`Agent365Sdk`, `semantic_kernel.*`, `agent_framework`, `microsoft.opentelemetry._genai.*`, `opentelemetry.instrumentation.openai_v2`, `opentelemetry.instrumentation.openai_agents`). The scope signal matters because LangChain, Semantic Kernel, Agent Framework, and OpenAI Agents all set `gen_ai.operation.name` *after* the span starts — a LangChain chat span begins life named `ChatOpenAI`, and a Semantic Kernel one as `chat.completions gpt-4o`. Spans classified only by instrumentation scope are GenAI with an unknown operation: they receive the common baggage attributes, but never the `invoke_agent`-only ones (caller agent details, `server.address`, `server.port`). From 6b582b5c49858074f3870a14e1a0f140a64cb2fb Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Wed, 23 Sep 2026 13:02:12 -0600 Subject: [PATCH 09/10] docs: simplify PR 265 changelog entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac0fa627..3fd0bc7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,7 @@ ([#269](https://github.com/microsoft/opentelemetry-distro-python/pull/269)) ### Bugs Fixed -- Stop adding A365 baggage and configured identity attributes to unrelated application spans, matching the .NET PR #99 GenAI-only processing behavior. -- Recognize supported GenAI instrumentation scopes and pre-rename span names at span start so LangChain (`ChatOpenAI`) and Semantic Kernel (`chat.completions `) spans keep their identity and baggage attributes and are no longer dropped by the exporter. -- Keep GenAI spans that declare an operation the processor does not model (`chain`, `embeddings`, `text_completion`, `generate_content`, `create_agent`) when a supported instrumentation scope emitted them, instead of treating an unrecognized `gen_ai.operation.name` attribute as non-GenAI. Such spans stay operation-unknown, so `invoke_agent`-only attributes are still withheld. +- Restrict A365 identity and baggage enrichment to recognized GenAI spans while preserving supported span-start signals. ([#265](https://github.com/microsoft/opentelemetry-distro-python/pull/265)) # 1.3.9 (2026-09-09) ### Features Added From 7cc123ee0cc1c8cca3118bfa38923afd5bbcb905 Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Wed, 23 Sep 2026 14:22:46 -0600 Subject: [PATCH 10/10] refactor: trim GenAI classification comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../opentelemetry/a365/core/constants.py | 25 ++--------- .../a365/core/exporters/span_processor.py | 43 +++---------------- .../a365/core/exporters/utils.py | 7 +-- tests/a365/test_span_processor.py | 22 +--------- 4 files changed, 11 insertions(+), 86 deletions(-) diff --git a/src/microsoft/opentelemetry/a365/core/constants.py b/src/microsoft/opentelemetry/a365/core/constants.py index f4771e65..150fda42 100644 --- a/src/microsoft/opentelemetry/a365/core/constants.py +++ b/src/microsoft/opentelemetry/a365/core/constants.py @@ -37,37 +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", "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", diff --git a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py index abe12eea..52199992 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/span_processor.py @@ -4,40 +4,9 @@ # license information. # -------------------------------------------------------------------------- -"""Span processor for propagating OpenTelemetry baggage entries onto spans. - -For every recognized GenAI span: - * Retrieve the current (or parent) context - * Obtain all baggage entries - * 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 - -Documented and opted-in custom baggage is propagated only to recognized GenAI -spans. A span is recognized as GenAI by evaluating these signals in order at -``on_start``: - - 1. An explicit ``gen_ai.operation.name`` attribute holding a recognized - 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 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 ``). - 4. A recognized ``gen_ai.operation.name`` baggage entry. - 5. The instrumentation scope (source) name of a supported GenAI - instrumentation: GenAI with an unknown operation. - -Signals 3 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. +"""Propagate A365 identity and baggage to recognized GenAI spans. + +Existing span attributes are never overwritten. """ from __future__ import annotations @@ -86,7 +55,7 @@ # mypy: disable-error-code="no-untyped-def" -# Generic / common tracing attributes propagated from baggage to qualifying GenAI spans +# Baggage attributes for all recognized GenAI spans. COMMON_ATTRIBUTES = [ TENANT_ID_KEY, CUSTOM_PARENT_SPAN_ID_KEY, @@ -113,7 +82,7 @@ SERVICE_NAME_KEY, ] -# Invoke Agent-specific attributes (only propagated to invoke_agent spans) +# Additional baggage attributes for invoke_agent spans. INVOKE_AGENT_ATTRIBUTES = [ GEN_AI_CALLER_AGENT_ID_KEY, GEN_AI_CALLER_AGENT_NAME_KEY, @@ -164,7 +133,6 @@ def __init__( def on_start(self, span, parent_context=None): # type: ignore[override] ctx = parent_context or context.get_current() - # Stamp static identity from configuration (never overwrite existing) try: existing = getattr(span, "attributes", {}) or {} except Exception: @@ -193,7 +161,6 @@ def on_start(self, span, parent_context=None): # type: ignore[override] except Exception: pass - # Refresh existing after stamping identity try: existing = getattr(span, "attributes", {}) or {} except Exception: diff --git a/src/microsoft/opentelemetry/a365/core/exporters/utils.py b/src/microsoft/opentelemetry/a365/core/exporters/utils.py index 5b049ec2..960effb1 100644 --- a/src/microsoft/opentelemetry/a365/core/exporters/utils.py +++ b/src/microsoft/opentelemetry/a365/core/exporters/utils.py @@ -60,12 +60,7 @@ # Maximum allowed span size in bytes (250KB) MAX_SPAN_SIZE_BYTES = 250 * 1024 -# Operation names that identify a span as eligible for export to the Agent 365 -# observability ingest service. Only spans whose gen_ai.operation.name matches -# one of these values are included; all other spans are filtered out. -# Deliberately narrower than the processor recognition set: baggage enrichment -# may run on additional GenAI spans (recognized by instrumentation scope or -# pre-rename span name) that are not exported to A365 ingest. +# Export eligibility is intentionally narrower than GenAI span recognition. GEN_AI_OPERATION_NAMES: frozenset[str] = frozenset( { INVOKE_AGENT_OPERATION_NAME, diff --git a/tests/a365/test_span_processor.py b/tests/a365/test_span_processor.py index fe4cc035..4cac3bee 100644 --- a/tests/a365/test_span_processor.py +++ b/tests/a365/test_span_processor.py @@ -171,7 +171,6 @@ def test_does_not_overwrite_existing_attributes(self): processor.on_start(span, parent_context=ctx) - # Should not have called set_attribute for tenant since it exists for call in span.set_attribute.call_args_list: self.assertNotEqual(call[0][0], "microsoft.tenant.id") @@ -204,10 +203,8 @@ def test_invoke_agent_attributes_not_propagated_for_other_spans(self): processor.on_start(span, parent_context=ctx) - # Tenant should be propagated (common) span.set_attribute.assert_any_call("microsoft.tenant.id", "my-tenant") - # Caller agent should NOT be propagated (invoke-agent only) for call in span.set_attribute.call_args_list: self.assertNotEqual(call[0][0], "microsoft.a365.caller.agent.id") @@ -614,7 +611,6 @@ def test_none_context(self): span.name = "invoke_agent Test" span.attributes = {GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME} - # Should not raise processor.on_start(span, parent_context=None) def test_on_end_does_not_raise(self): @@ -635,12 +631,7 @@ def test_invoke_agent_attributes_list(self): class TestA365SpanProcessorGenAiInstrumentationSignals(unittest.TestCase): - """Spans that only become identifiable as GenAI *after* ``on_start``. - - LangChain and Semantic Kernel set ``gen_ai.operation.name`` (and rename the - span) once the call completes, so ``on_start`` sees only the raw span name. - The instrumentation scope is the signal that is already available. - """ + """Cover GenAI signals available at span start.""" def _baggage_context(self): ctx = baggage.set_baggage("microsoft.tenant.id", "baggage-tenant", context.get_current()) @@ -674,7 +665,6 @@ def test_initial_span_names_cover_semantic_kernel_completions(self): # -- positive cases -- def test_langchain_chat_model_span_is_enriched(self): - """The initial LangChain chat-model span is named after the model class.""" processor = A365SpanProcessor() span = _mock_span("ChatOpenAI", scope_name=LANGCHAIN_SCOPE) @@ -699,7 +689,6 @@ def test_openai_agents_workflow_span_is_enriched(self): self._assert_enriched(span) def test_semantic_kernel_chat_completions_span_is_enriched(self): - """Semantic Kernel starts chat spans as ``chat.completions ``.""" processor = A365SpanProcessor() span = _mock_span("chat.completions gpt-4o", scope_name=SEMANTIC_KERNEL_SCOPE) @@ -708,7 +697,6 @@ def test_semantic_kernel_chat_completions_span_is_enriched(self): self._assert_enriched(span) def test_semantic_kernel_chat_completions_span_enriched_without_scope(self): - """The known initial span name alone is enough, independent of scope.""" processor = A365SpanProcessor() span = _mock_span("chat.completions gpt-4o") @@ -877,13 +865,7 @@ def test_missing_instrumentation_scope_is_tolerated(self): class TestA365SpanProcessorWithTracerProvider(unittest.TestCase): - """End-to-end checks against a real SDK ``TracerProvider``. - - Mirrors the distro's registration order: ``A365SpanProcessor`` is attached - when the provider is built, platform processors are attached later by the - instrumentors. ``A365SpanProcessor.on_start`` therefore runs *before* the - Semantic Kernel processor renames the span and sets its operation name. - """ + """Verify span-start behavior with a real TracerProvider.""" def setUp(self): self.provider = TracerProvider()