From 46e9a05dce242e6dcb1ebab7f4dd0742b1addf75 Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Tue, 5 May 2026 12:55:54 -0700 Subject: [PATCH 1/3] Add GenAI main-agent attribution processors --- src/microsoft/opentelemetry/_constants.py | 11 ++ src/microsoft/opentelemetry/_distro.py | 19 ++ .../_genai/main_agent/__init__.py | 12 ++ .../_genai/main_agent/_processor.py | 134 +++++++++++++ .../main_agent/test_log_record_processor.py | 95 ++++++++++ tests/genai/main_agent/test_span_processor.py | 178 ++++++++++++++++++ 6 files changed, 449 insertions(+) create mode 100644 src/microsoft/opentelemetry/_genai/main_agent/__init__.py create mode 100644 src/microsoft/opentelemetry/_genai/main_agent/_processor.py create mode 100644 tests/genai/main_agent/test_log_record_processor.py create mode 100644 tests/genai/main_agent/test_span_processor.py diff --git a/src/microsoft/opentelemetry/_constants.py b/src/microsoft/opentelemetry/_constants.py index b1047c77..17fede0e 100644 --- a/src/microsoft/opentelemetry/_constants.py +++ b/src/microsoft/opentelemetry/_constants.py @@ -109,5 +109,16 @@ A365_EXPORTER_TIMEOUT_MS_ARG = "a365_exporter_timeout_ms" A365_MAX_EXPORT_BATCH_SIZE_ARG = "a365_max_export_batch_size" +# --- GenAI Main Agent Constants --- + +# Target attribute keys written by the GenAI main-agent processors so that +# downstream telemetry (spans + logs) is attributed to the user-facing +# ("main") agent rather than internal sub-agents in a multi-agent system. +GEN_AI_MAIN_AGENT_NAME_KEY = "microsoft.gen_ai.main_agent.name" +GEN_AI_MAIN_AGENT_ID_KEY = "microsoft.gen_ai.main_agent.id" +GEN_AI_MAIN_AGENT_VERSION_KEY = "microsoft.gen_ai.main_agent.version" +GEN_AI_MAIN_AGENT_CONVERSATION_ID_KEY = "microsoft.gen_ai.main_agent.conversation_id" +GEN_AI_MAIN_AGENT_ATTRIBUTE_PREFIX = "microsoft.gen_ai.main_agent." + # --- Version propagation for distro to exporter --- MICROSOFT_OPENTELEMETRY_VERSION_ENV = "microsoft_opentelemetry_version" diff --git a/src/microsoft/opentelemetry/_distro.py b/src/microsoft/opentelemetry/_distro.py index e181034d..25aa4866 100644 --- a/src/microsoft/opentelemetry/_distro.py +++ b/src/microsoft/opentelemetry/_distro.py @@ -62,6 +62,10 @@ _SPECTRA_PROTOCOL_ENV, MICROSOFT_OPENTELEMETRY_VERSION_ENV, ) +from microsoft.opentelemetry._genai.main_agent import ( + GenAIMainAgentLogRecordProcessor, + GenAIMainAgentSpanProcessor, +) from microsoft.opentelemetry._instrumentation import get_dist_dependency_conflicts from microsoft.opentelemetry._otlp import is_otlp_enabled from microsoft.opentelemetry._sdkstats._state import ( @@ -235,6 +239,21 @@ def use_microsoft_opentelemetry(**kwargs: object) -> None: # pylint: disable=to # ---- SDKStats: record distro feature flag ---- set_sdkstats_feature(SdkStatsFeature.DISTRO) + # ---- GenAI main-agent attribute propagation (always on) ---- + # Prepended to the processor lists so on_start/on_emit run BEFORE any + # Batch* export processor appended below; this enriches once per + # span/log and is then visible to every downstream exporter. + if not otel_kwargs.get(DISABLE_TRACING_ARG, False): + otel_kwargs[SPAN_PROCESSORS_ARG] = [ + GenAIMainAgentSpanProcessor(), + *list(otel_kwargs.get(SPAN_PROCESSORS_ARG) or []), + ] + if not otel_kwargs.get(DISABLE_LOGGING_ARG, False): + otel_kwargs[LOG_RECORD_PROCESSORS_ARG] = [ + GenAIMainAgentLogRecordProcessor(), + *list(otel_kwargs.get(LOG_RECORD_PROCESSORS_ARG) or []), + ] + # ---- OTLP exporters (append to user-supplied processors/readers) ---- _append_otlp_components(otel_kwargs) if is_otlp_enabled(): diff --git a/src/microsoft/opentelemetry/_genai/main_agent/__init__.py b/src/microsoft/opentelemetry/_genai/main_agent/__init__.py new file mode 100644 index 00000000..6c6de7b9 --- /dev/null +++ b/src/microsoft/opentelemetry/_genai/main_agent/__init__.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from microsoft.opentelemetry._genai.main_agent._processor import ( + GenAIMainAgentLogRecordProcessor, + GenAIMainAgentSpanProcessor, +) + +__all__ = [ + "GenAIMainAgentLogRecordProcessor", + "GenAIMainAgentSpanProcessor", +] diff --git a/src/microsoft/opentelemetry/_genai/main_agent/_processor.py b/src/microsoft/opentelemetry/_genai/main_agent/_processor.py new file mode 100644 index 00000000..88f32aa9 --- /dev/null +++ b/src/microsoft/opentelemetry/_genai/main_agent/_processor.py @@ -0,0 +1,134 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Span and log-record processors that propagate +``microsoft.gen_ai.main_agent.*`` attributes from the top-level +(user-facing) GenAI agent so that all downstream telemetry is attributed +to the main agent rather than internal sub-agents in a multi-agent system. +""" + +from microsoft.opentelemetry._constants import ( + GEN_AI_MAIN_AGENT_ATTRIBUTE_PREFIX, + GEN_AI_MAIN_AGENT_CONVERSATION_ID_KEY, + GEN_AI_MAIN_AGENT_ID_KEY, + GEN_AI_MAIN_AGENT_NAME_KEY, + GEN_AI_MAIN_AGENT_VERSION_KEY, +) +from microsoft.opentelemetry.a365.core.constants import ( + GEN_AI_AGENT_ID_KEY, + GEN_AI_AGENT_NAME_KEY, + GEN_AI_AGENT_VERSION_KEY, + GEN_AI_CONVERSATION_ID_KEY, + GEN_AI_OPERATION_NAME_KEY, + INVOKE_AGENT_OPERATION_NAME, +) +from opentelemetry import context as context_api +from opentelemetry import trace +from opentelemetry.sdk._logs import LogRecordProcessor +from opentelemetry.sdk._logs._internal import ReadWriteLogRecord +from opentelemetry.sdk.trace import ReadableSpan, Span +from opentelemetry.sdk.trace.export import SpanProcessor + +# Each row: (target attribute on current span, +# primary source attribute on parent span, +# fallback source attribute on parent span) +_PROPAGATION_TABLE: tuple[tuple[str, str, str], ...] = ( + (GEN_AI_MAIN_AGENT_NAME_KEY, GEN_AI_MAIN_AGENT_NAME_KEY, GEN_AI_AGENT_NAME_KEY), + (GEN_AI_MAIN_AGENT_ID_KEY, GEN_AI_MAIN_AGENT_ID_KEY, GEN_AI_AGENT_ID_KEY), + (GEN_AI_MAIN_AGENT_VERSION_KEY, GEN_AI_MAIN_AGENT_VERSION_KEY, GEN_AI_AGENT_VERSION_KEY), + ( + GEN_AI_MAIN_AGENT_CONVERSATION_ID_KEY, + GEN_AI_MAIN_AGENT_CONVERSATION_ID_KEY, + GEN_AI_CONVERSATION_ID_KEY, + ), +) + +# Used at on_end to copy the current span's own gen_ai.* attributes onto the +# microsoft.gen_ai.main_agent.* attributes when the span is the top-level +# invoke_agent span and no main_agent.* attribute has been propagated yet. +_SELF_COPY_TABLE: tuple[tuple[str, str], ...] = tuple( + (target, fallback) for target, _primary, fallback in _PROPAGATION_TABLE +) + + +class GenAIMainAgentSpanProcessor(SpanProcessor): + """Propagates ``microsoft.gen_ai.main_agent.*`` attributes onto spans. + + On ``on_start``: copies main-agent attributes from the parent span (or + falls back to the parent's ``gen_ai.agent.*`` / ``gen_ai.conversation.id`` + attributes) onto the new span. + + On ``on_end``: when the span is itself an ``invoke_agent`` operation and + has not already been enriched, copies its own ``gen_ai.agent.*`` / + ``gen_ai.conversation.id`` attributes onto ``microsoft.gen_ai.main_agent.*`` + so the top-level agent identifies itself as the main agent. + """ + + def __init__(self, service_name: str | None = None): + self.service_name = service_name + + def on_start(self, span: Span, parent_context: context_api.Context | None = None) -> None: + parent = trace.get_current_span(parent_context) + if not parent.get_span_context().is_valid: + return + + parent_attributes = getattr(parent, "attributes", None) or {} + for target, primary, fallback in _PROPAGATION_TABLE: + value = parent_attributes.get(primary) + if value is None: + value = parent_attributes.get(fallback) + if value is not None: + span.set_attribute(target, value) + + def on_end(self, span: ReadableSpan) -> None: + attributes = span.attributes or {} + if attributes.get(GEN_AI_OPERATION_NAME_KEY) != INVOKE_AGENT_OPERATION_NAME: + return + + for key in attributes: + if key.startswith(GEN_AI_MAIN_AGENT_ATTRIBUTE_PREFIX): + return + + # The SDK invokes ``on_end`` with the underlying writable ``Span`` even + # though the type hint exposes ``ReadableSpan``; existing distro + # processors rely on this same behaviour. + writable_span: Span = span # type: ignore[assignment] + for target, source in _SELF_COPY_TABLE: + value = attributes.get(source) + if value is not None: + writable_span.set_attribute(target, value) + + def shutdown(self) -> None: + pass + + def force_flush(self, timeout_millis: int = 30000) -> bool: + return True + + +class GenAIMainAgentLogRecordProcessor(LogRecordProcessor): + """Copies any ``microsoft.gen_ai.main_agent.*`` attributes from the + current span onto every emitted log record. + """ + + def on_emit(self, log_record: ReadWriteLogRecord) -> None: + span = trace.get_current_span() + if not span.get_span_context().is_valid: + return + + span_attributes = getattr(span, "attributes", None) or {} + main_agent_attributes = { + key: value for key, value in span_attributes.items() if key.startswith(GEN_AI_MAIN_AGENT_ATTRIBUTE_PREFIX) + } + if not main_agent_attributes: + return + + if log_record.log_record.attributes is None: + log_record.log_record.attributes = {} + for key, value in main_agent_attributes.items(): + log_record.log_record.attributes[key] = value + + def shutdown(self) -> None: + pass + + def force_flush(self, timeout_millis: int = 30000) -> bool: + return True diff --git a/tests/genai/main_agent/test_log_record_processor.py b/tests/genai/main_agent/test_log_record_processor.py new file mode 100644 index 00000000..f025faeb --- /dev/null +++ b/tests/genai/main_agent/test_log_record_processor.py @@ -0,0 +1,95 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for GenAIMainAgentLogRecordProcessor.""" + +import unittest +from unittest.mock import MagicMock, Mock, patch + +from microsoft.opentelemetry._constants import ( + GEN_AI_MAIN_AGENT_ID_KEY, + GEN_AI_MAIN_AGENT_NAME_KEY, +) +from microsoft.opentelemetry._genai.main_agent._processor import ( + GenAIMainAgentLogRecordProcessor, +) + + +def _mock_span(attributes: dict, valid: bool = True) -> Mock: + span = Mock() + span.attributes = attributes + span_context = Mock() + span_context.is_valid = valid + span.get_span_context.return_value = span_context + return span + + +def _mock_log_record(attributes): + log_data = MagicMock() + log_data.log_record.attributes = attributes + return log_data + + +class TestGenAIMainAgentLogRecordProcessorOnEmit(unittest.TestCase): + def setUp(self) -> None: + self.processor = GenAIMainAgentLogRecordProcessor() + + def test_no_current_span_does_nothing(self): + log_data = _mock_log_record({"existing": "value"}) + + with patch( + "microsoft.opentelemetry._genai.main_agent._processor.trace.get_current_span", + return_value=_mock_span({}, valid=False), + ): + self.processor.on_emit(log_data) + + self.assertEqual(log_data.log_record.attributes, {"existing": "value"}) + + def test_span_without_main_agent_attrs_does_nothing(self): + log_data = _mock_log_record({"existing": "value"}) + + with patch( + "microsoft.opentelemetry._genai.main_agent._processor.trace.get_current_span", + return_value=_mock_span({"gen_ai.agent.name": "x"}), + ): + self.processor.on_emit(log_data) + + self.assertEqual(log_data.log_record.attributes, {"existing": "value"}) + + def test_copies_main_agent_attrs_to_log_record(self): + log_data = _mock_log_record({"existing": "value"}) + span_attrs = { + GEN_AI_MAIN_AGENT_NAME_KEY: "main", + GEN_AI_MAIN_AGENT_ID_KEY: "id-1", + "unrelated": "skip-me", + } + + with patch( + "microsoft.opentelemetry._genai.main_agent._processor.trace.get_current_span", + return_value=_mock_span(span_attrs), + ): + self.processor.on_emit(log_data) + + self.assertEqual(log_data.log_record.attributes[GEN_AI_MAIN_AGENT_NAME_KEY], "main") + self.assertEqual(log_data.log_record.attributes[GEN_AI_MAIN_AGENT_ID_KEY], "id-1") + self.assertEqual(log_data.log_record.attributes["existing"], "value") + self.assertNotIn("unrelated", log_data.log_record.attributes) + + def test_initializes_attributes_dict_when_none(self): + log_data = _mock_log_record(None) + span_attrs = {GEN_AI_MAIN_AGENT_NAME_KEY: "main"} + + with patch( + "microsoft.opentelemetry._genai.main_agent._processor.trace.get_current_span", + return_value=_mock_span(span_attrs), + ): + self.processor.on_emit(log_data) + + self.assertEqual(log_data.log_record.attributes, {GEN_AI_MAIN_AGENT_NAME_KEY: "main"}) + + +class TestGenAIMainAgentLogRecordProcessorLifecycle(unittest.TestCase): + def test_shutdown_and_force_flush_are_noops(self): + processor = GenAIMainAgentLogRecordProcessor() + processor.shutdown() + self.assertTrue(processor.force_flush()) diff --git a/tests/genai/main_agent/test_span_processor.py b/tests/genai/main_agent/test_span_processor.py new file mode 100644 index 00000000..a03feac6 --- /dev/null +++ b/tests/genai/main_agent/test_span_processor.py @@ -0,0 +1,178 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for GenAIMainAgentSpanProcessor.""" + +import unittest +from unittest.mock import MagicMock, Mock, patch + +from microsoft.opentelemetry._constants import ( + GEN_AI_MAIN_AGENT_CONVERSATION_ID_KEY, + GEN_AI_MAIN_AGENT_ID_KEY, + GEN_AI_MAIN_AGENT_NAME_KEY, + GEN_AI_MAIN_AGENT_VERSION_KEY, +) +from microsoft.opentelemetry._genai.main_agent._processor import ( + GenAIMainAgentSpanProcessor, +) +from microsoft.opentelemetry.a365.core.constants import ( + GEN_AI_AGENT_ID_KEY, + GEN_AI_AGENT_NAME_KEY, + GEN_AI_AGENT_VERSION_KEY, + GEN_AI_CONVERSATION_ID_KEY, + GEN_AI_OPERATION_NAME_KEY, + INVOKE_AGENT_OPERATION_NAME, +) + + +def _mock_parent_span(attributes: dict, valid: bool = True) -> Mock: + parent = Mock() + parent.attributes = attributes + span_context = Mock() + span_context.is_valid = valid + parent.get_span_context.return_value = span_context + return parent + + +def _mock_invalid_parent_span() -> Mock: + parent = Mock() + span_context = Mock() + span_context.is_valid = False + parent.get_span_context.return_value = span_context + return parent + + +class TestGenAIMainAgentSpanProcessorOnStart(unittest.TestCase): + """on_start propagation from parent span to current span.""" + + def setUp(self) -> None: + self.processor = GenAIMainAgentSpanProcessor() + self.span = MagicMock() + + def test_no_valid_parent_does_nothing(self): + with patch( + "microsoft.opentelemetry._genai.main_agent._processor.trace.get_current_span", + return_value=_mock_invalid_parent_span(), + ): + self.processor.on_start(self.span, parent_context=None) + + self.span.set_attribute.assert_not_called() + + def test_parent_with_no_relevant_attributes_does_nothing(self): + with patch( + "microsoft.opentelemetry._genai.main_agent._processor.trace.get_current_span", + return_value=_mock_parent_span({"unrelated": "value"}), + ): + self.processor.on_start(self.span, parent_context=None) + + self.span.set_attribute.assert_not_called() + + def test_parent_with_only_fallbacks_copies_fallbacks(self): + parent_attrs = { + GEN_AI_AGENT_NAME_KEY: "main", + GEN_AI_AGENT_ID_KEY: "id-1", + GEN_AI_AGENT_VERSION_KEY: "v1", + GEN_AI_CONVERSATION_ID_KEY: "conv-1", + } + with patch( + "microsoft.opentelemetry._genai.main_agent._processor.trace.get_current_span", + return_value=_mock_parent_span(parent_attrs), + ): + self.processor.on_start(self.span, parent_context=None) + + self.span.set_attribute.assert_any_call(GEN_AI_MAIN_AGENT_NAME_KEY, "main") + self.span.set_attribute.assert_any_call(GEN_AI_MAIN_AGENT_ID_KEY, "id-1") + self.span.set_attribute.assert_any_call(GEN_AI_MAIN_AGENT_VERSION_KEY, "v1") + self.span.set_attribute.assert_any_call(GEN_AI_MAIN_AGENT_CONVERSATION_ID_KEY, "conv-1") + self.assertEqual(self.span.set_attribute.call_count, 4) + + def test_parent_with_primary_wins_over_fallback(self): + parent_attrs = { + GEN_AI_MAIN_AGENT_NAME_KEY: "primary", + GEN_AI_AGENT_NAME_KEY: "fallback", + } + with patch( + "microsoft.opentelemetry._genai.main_agent._processor.trace.get_current_span", + return_value=_mock_parent_span(parent_attrs), + ): + self.processor.on_start(self.span, parent_context=None) + + self.span.set_attribute.assert_called_once_with(GEN_AI_MAIN_AGENT_NAME_KEY, "primary") + + def test_parent_with_mixed_primary_and_fallback(self): + parent_attrs = { + GEN_AI_MAIN_AGENT_NAME_KEY: "primary-name", + GEN_AI_AGENT_ID_KEY: "fallback-id", + } + with patch( + "microsoft.opentelemetry._genai.main_agent._processor.trace.get_current_span", + return_value=_mock_parent_span(parent_attrs), + ): + self.processor.on_start(self.span, parent_context=None) + + self.span.set_attribute.assert_any_call(GEN_AI_MAIN_AGENT_NAME_KEY, "primary-name") + self.span.set_attribute.assert_any_call(GEN_AI_MAIN_AGENT_ID_KEY, "fallback-id") + self.assertEqual(self.span.set_attribute.call_count, 2) + + +class TestGenAIMainAgentSpanProcessorOnEnd(unittest.TestCase): + """on_end self-copy when this span itself is the top-level invoke_agent.""" + + def setUp(self) -> None: + self.processor = GenAIMainAgentSpanProcessor() + + def test_skipped_when_not_invoke_agent(self): + span = MagicMock() + span.attributes = {GEN_AI_OPERATION_NAME_KEY: "chat"} + + self.processor.on_end(span) + + span.set_attribute.assert_not_called() + + def test_skipped_when_main_agent_attribute_already_present(self): + span = MagicMock() + span.attributes = { + GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME, + GEN_AI_MAIN_AGENT_NAME_KEY: "already-set", + GEN_AI_AGENT_NAME_KEY: "self", + } + + self.processor.on_end(span) + + span.set_attribute.assert_not_called() + + def test_copies_self_attributes_when_invoke_agent_and_unenriched(self): + span = MagicMock() + span.attributes = { + GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME, + GEN_AI_AGENT_NAME_KEY: "self-name", + GEN_AI_AGENT_ID_KEY: "self-id", + GEN_AI_AGENT_VERSION_KEY: "self-v", + GEN_AI_CONVERSATION_ID_KEY: "self-conv", + } + + self.processor.on_end(span) + + span.set_attribute.assert_any_call(GEN_AI_MAIN_AGENT_NAME_KEY, "self-name") + span.set_attribute.assert_any_call(GEN_AI_MAIN_AGENT_ID_KEY, "self-id") + span.set_attribute.assert_any_call(GEN_AI_MAIN_AGENT_VERSION_KEY, "self-v") + span.set_attribute.assert_any_call(GEN_AI_MAIN_AGENT_CONVERSATION_ID_KEY, "self-conv") + self.assertEqual(span.set_attribute.call_count, 4) + + def test_copies_only_present_attributes(self): + span = MagicMock() + span.attributes = { + GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME, + GEN_AI_AGENT_NAME_KEY: "only-name", + } + + self.processor.on_end(span) + + span.set_attribute.assert_called_once_with(GEN_AI_MAIN_AGENT_NAME_KEY, "only-name") + + +class TestGenAIMainAgentSpanProcessorLifecycle(unittest.TestCase): + def test_shutdown_and_force_flush_are_noops(self): + processor = GenAIMainAgentSpanProcessor() + processor.shutdown() + self.assertTrue(processor.force_flush()) From f2c7bdc40d7c2e3146509041777287bc94cbe3b2 Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Tue, 5 May 2026 13:27:23 -0700 Subject: [PATCH 2/3] Address comments --- .../opentelemetry/_genai/main_agent/_processor.py | 14 ++++---------- tests/genai/main_agent/test_span_processor.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/microsoft/opentelemetry/_genai/main_agent/_processor.py b/src/microsoft/opentelemetry/_genai/main_agent/_processor.py index 88f32aa9..65ef3dc2 100644 --- a/src/microsoft/opentelemetry/_genai/main_agent/_processor.py +++ b/src/microsoft/opentelemetry/_genai/main_agent/_processor.py @@ -24,8 +24,7 @@ ) from opentelemetry import context as context_api from opentelemetry import trace -from opentelemetry.sdk._logs import LogRecordProcessor -from opentelemetry.sdk._logs._internal import ReadWriteLogRecord +from opentelemetry.sdk._logs import LogRecordProcessor, ReadWriteLogRecord from opentelemetry.sdk.trace import ReadableSpan, Span from opentelemetry.sdk.trace.export import SpanProcessor @@ -64,9 +63,6 @@ class GenAIMainAgentSpanProcessor(SpanProcessor): so the top-level agent identifies itself as the main agent. """ - def __init__(self, service_name: str | None = None): - self.service_name = service_name - def on_start(self, span: Span, parent_context: context_api.Context | None = None) -> None: parent = trace.get_current_span(parent_context) if not parent.get_span_context().is_valid: @@ -89,14 +85,12 @@ def on_end(self, span: ReadableSpan) -> None: if key.startswith(GEN_AI_MAIN_AGENT_ATTRIBUTE_PREFIX): return - # The SDK invokes ``on_end`` with the underlying writable ``Span`` even - # though the type hint exposes ``ReadableSpan``; existing distro - # processors rely on this same behaviour. - writable_span: Span = span # type: ignore[assignment] + if not hasattr(span, "set_attribute"): + return for target, source in _SELF_COPY_TABLE: value = attributes.get(source) if value is not None: - writable_span.set_attribute(target, value) + span.set_attribute(target, value) # type: ignore[attr-defined] def shutdown(self) -> None: pass diff --git a/tests/genai/main_agent/test_span_processor.py b/tests/genai/main_agent/test_span_processor.py index a03feac6..bf557c86 100644 --- a/tests/genai/main_agent/test_span_processor.py +++ b/tests/genai/main_agent/test_span_processor.py @@ -170,6 +170,18 @@ def test_copies_only_present_attributes(self): span.set_attribute.assert_called_once_with(GEN_AI_MAIN_AGENT_NAME_KEY, "only-name") + def test_noop_when_span_has_no_set_attribute(self): + # ReadableSpan-only objects (no ``set_attribute``) must not raise. + span = MagicMock(spec=["attributes"]) + span.attributes = { + GEN_AI_OPERATION_NAME_KEY: INVOKE_AGENT_OPERATION_NAME, + GEN_AI_AGENT_NAME_KEY: "self-name", + } + + self.processor.on_end(span) # must not raise + + self.assertFalse(hasattr(span, "set_attribute")) + class TestGenAIMainAgentSpanProcessorLifecycle(unittest.TestCase): def test_shutdown_and_force_flush_are_noops(self): From 21d8aa1e7d2e8129e5fca3a7e2ea28e7ca3b4cee Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Tue, 5 May 2026 13:46:43 -0700 Subject: [PATCH 3/3] Fix pyright errors --- src/microsoft/opentelemetry/_genai/main_agent/_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/microsoft/opentelemetry/_genai/main_agent/_processor.py b/src/microsoft/opentelemetry/_genai/main_agent/_processor.py index 65ef3dc2..5e6f0ba4 100644 --- a/src/microsoft/opentelemetry/_genai/main_agent/_processor.py +++ b/src/microsoft/opentelemetry/_genai/main_agent/_processor.py @@ -119,7 +119,7 @@ def on_emit(self, log_record: ReadWriteLogRecord) -> None: if log_record.log_record.attributes is None: log_record.log_record.attributes = {} for key, value in main_agent_attributes.items(): - log_record.log_record.attributes[key] = value + log_record.log_record.attributes[key] = value # type: ignore[index] def shutdown(self) -> None: pass