From 9e563360f23197d681d22a5b1b5bb5606949ffd4 Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Mon, 15 Jun 2026 13:52:44 -0700 Subject: [PATCH 1/2] Move capture_message_content kwarg to utils --- .../genai/openai_agents/__init__.py | 56 +------ .../genai/openai_agents/span_processor.py | 29 +--- .../tests/test_tracer.py | 4 + .../tests/test_zz_coverage_improvements.py | 73 +-------- .../src/opentelemetry/util/genai/types.py | 14 ++ .../src/opentelemetry/util/genai/utils.py | 52 +++++++ .../tests/test_utils.py | 138 ++++++++++++++++++ 7 files changed, 220 insertions(+), 146 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/__init__.py b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/__init__.py index 955c3f1a..927d1917 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/__init__.py @@ -18,6 +18,8 @@ ) from opentelemetry.semconv.schemas import Schemas from opentelemetry.trace import get_tracer +from opentelemetry.util.genai.types import ContentCapturingMode +from opentelemetry.util.genai.utils import resolve_capture_message_content from .package import _instruments from .span_processor import ( @@ -41,9 +43,7 @@ logger = logging.getLogger(__name__) -_CONTENT_CAPTURE_ENV = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" _SYSTEM_OVERRIDE_ENV = "OTEL_INSTRUMENTATION_OPENAI_AGENTS_SYSTEM" -_CAPTURE_CONTENT_ENV = "OTEL_INSTRUMENTATION_OPENAI_AGENTS_CAPTURE_CONTENT" _CAPTURE_METRICS_ENV = "OTEL_INSTRUMENTATION_OPENAI_AGENTS_CAPTURE_METRICS" @@ -70,47 +70,6 @@ def _resolve_system(value: str | None) -> str: return value -def _resolve_content_mode(value: Any) -> ContentCaptureMode: - if isinstance(value, ContentCaptureMode): - return value - if isinstance(value, bool): - return ( - ContentCaptureMode.SPAN_AND_EVENT - if value - else ContentCaptureMode.NO_CONTENT - ) - - if value is None: - return ContentCaptureMode.SPAN_AND_EVENT - - text = str(value).strip().lower() - if not text: - return ContentCaptureMode.SPAN_AND_EVENT - - mapping = { - "span_only": ContentCaptureMode.SPAN_ONLY, - "span-only": ContentCaptureMode.SPAN_ONLY, - "span": ContentCaptureMode.SPAN_ONLY, - "event_only": ContentCaptureMode.EVENT_ONLY, - "event-only": ContentCaptureMode.EVENT_ONLY, - "event": ContentCaptureMode.EVENT_ONLY, - "span_and_event": ContentCaptureMode.SPAN_AND_EVENT, - "span-and-event": ContentCaptureMode.SPAN_AND_EVENT, - "span_and_events": ContentCaptureMode.SPAN_AND_EVENT, - "all": ContentCaptureMode.SPAN_AND_EVENT, - "true": ContentCaptureMode.SPAN_AND_EVENT, - "1": ContentCaptureMode.SPAN_AND_EVENT, - "yes": ContentCaptureMode.SPAN_AND_EVENT, - "no_content": ContentCaptureMode.NO_CONTENT, - "false": ContentCaptureMode.NO_CONTENT, - "0": ContentCaptureMode.NO_CONTENT, - "no": ContentCaptureMode.NO_CONTENT, - "none": ContentCaptureMode.NO_CONTENT, - } - - return mapping.get(text, ContentCaptureMode.SPAN_AND_EVENT) - - def _resolve_bool(value: Any, default: bool) -> bool: if value is None: return default @@ -148,12 +107,9 @@ def _instrument(self, **kwargs) -> None: ) system = _resolve_system(system_override) - content_override = kwargs.get("capture_message_content") - if content_override is None: - content_override = os.getenv(_CONTENT_CAPTURE_ENV) or os.getenv( - _CAPTURE_CONTENT_ENV - ) - content_mode = _resolve_content_mode(content_override) + content_mode = resolve_capture_message_content( + kwargs.get("capture_message_content") + ) metrics_override = kwargs.get("capture_metrics") if metrics_override is None: @@ -171,7 +127,7 @@ def _instrument(self, **kwargs) -> None: tracer=tracer, system_name=system, include_sensitive_data=content_mode - != ContentCaptureMode.NO_CONTENT, + != ContentCapturingMode.NO_CONTENT, content_mode=content_mode, metrics_enabled=metrics_enabled, agent_name=agent_name, diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/span_processor.py b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/span_processor.py index f78552f1..4d6437fb 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/span_processor.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/span_processor.py @@ -23,7 +23,6 @@ import logging from dataclasses import dataclass from datetime import datetime, timezone -from enum import Enum from typing import TYPE_CHECKING, Any, Dict, Iterator, Optional, Sequence from urllib.parse import urlparse @@ -73,8 +72,13 @@ Tracer, set_span_in_context, ) +from opentelemetry.util.genai.types import ContentCapturingMode from opentelemetry.util.types import AttributeValue +# Backwards-compatible alias for the canonical ``ContentCapturingMode`` enum +# now defined in ``opentelemetry.util.genai.types``. +ContentCaptureMode = ContentCapturingMode + # Import all semantic convention constants # ---- GenAI semantic convention helpers (embedded from constants.py) ---- @@ -307,29 +311,6 @@ def normalize_output_type(output_type: Optional[str]) -> str: GEN_AI_SYSTEM_KEY = getattr(GenAIAttributes, "GEN_AI_SYSTEM", "gen_ai.system") -class ContentCaptureMode(Enum): - """Controls whether sensitive content is recorded on spans, events, or both.""" - - NO_CONTENT = "no_content" - SPAN_ONLY = "span_only" - EVENT_ONLY = "event_only" - SPAN_AND_EVENT = "span_and_event" - - @property - def capture_in_span(self) -> bool: - return self in ( - ContentCaptureMode.SPAN_ONLY, - ContentCaptureMode.SPAN_AND_EVENT, - ) - - @property - def capture_in_event(self) -> bool: - return self in ( - ContentCaptureMode.EVENT_ONLY, - ContentCaptureMode.SPAN_AND_EVENT, - ) - - @dataclass class ContentPayload: """Container for normalized content associated with a span.""" diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_tracer.py b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_tracer.py index 72379388..824ac016 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_tracer.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_tracer.py @@ -67,6 +67,10 @@ def _instrument_with_provider(**instrument_kwargs): exporter = InMemorySpanExporter() provider.add_span_processor(SimpleSpanProcessor(exporter)) + # Most tests in this file inspect captured content, so default to + # ``span_and_event`` unless the caller overrides. + instrument_kwargs.setdefault("capture_message_content", "span_and_event") + instrumentor = OpenAIAgentsInstrumentor() instrumentor.instrument(tracer_provider=provider, **instrument_kwargs) diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_zz_coverage_improvements.py b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_zz_coverage_improvements.py index d9880806..c34b89ac 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_zz_coverage_improvements.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_zz_coverage_improvements.py @@ -8,7 +8,7 @@ Tests for improving coverage of OpenAI Agents instrumentation. This file targets uncovered lines identified in the coverage report: -- __init__.py: _resolve_system, _resolve_bool, _resolve_content_mode +- __init__.py: _resolve_system, _resolve_bool - span_processor.py: normalization utilities, span data handlers, message normalization, output type inference, metrics recording, and various edge cases. @@ -158,77 +158,6 @@ def test_resolve_bool_returns_default_for_unknown(self): assert init_module._resolve_bool("maybe", False) is False -class TestResolveContentMode: - """Tests for _resolve_content_mode function.""" - - def test_resolve_content_mode_returns_value_for_enum(self): - sp, init_module = _get_modules() - result = init_module._resolve_content_mode( - sp.ContentCaptureMode.SPAN_ONLY - ) - assert result == sp.ContentCaptureMode.SPAN_ONLY - - def test_resolve_content_mode_for_bool_true(self): - sp, init_module = _get_modules() - result = init_module._resolve_content_mode(True) - assert result == sp.ContentCaptureMode.SPAN_AND_EVENT - - def test_resolve_content_mode_for_bool_false(self): - sp, init_module = _get_modules() - result = init_module._resolve_content_mode(False) - assert result == sp.ContentCaptureMode.NO_CONTENT - - def test_resolve_content_mode_for_none(self): - sp, init_module = _get_modules() - result = init_module._resolve_content_mode(None) - assert result == sp.ContentCaptureMode.SPAN_AND_EVENT - - def test_resolve_content_mode_for_empty_string(self): - sp, init_module = _get_modules() - result = init_module._resolve_content_mode("") - assert result == sp.ContentCaptureMode.SPAN_AND_EVENT - - def test_resolve_content_mode_span_only_variants(self): - sp, init_module = _get_modules() - for value in ["span_only", "span-only", "span"]: - result = init_module._resolve_content_mode(value) - assert result == sp.ContentCaptureMode.SPAN_ONLY, ( - f"Failed for {value}" - ) - - def test_resolve_content_mode_event_only_variants(self): - sp, init_module = _get_modules() - for value in ["event_only", "event-only", "event"]: - result = init_module._resolve_content_mode(value) - assert result == sp.ContentCaptureMode.EVENT_ONLY, ( - f"Failed for {value}" - ) - - def test_resolve_content_mode_span_and_event_variants(self): - sp, init_module = _get_modules() - for value in [ - "span_and_event", - "span-and-event", - "span_and_events", - "all", - "true", - "1", - "yes", - ]: - result = init_module._resolve_content_mode(value) - assert result == sp.ContentCaptureMode.SPAN_AND_EVENT, ( - f"Failed for {value}" - ) - - def test_resolve_content_mode_no_content_variants(self): - sp, init_module = _get_modules() - for value in ["no_content", "false", "0", "no", "none"]: - result = init_module._resolve_content_mode(value) - assert result == sp.ContentCaptureMode.NO_CONTENT, ( - f"Failed for {value}" - ) - - # ============================================================================ # Tests for span_processor.py normalization utilities # ============================================================================ diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py index 6ec3266c..f388e5c4 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -26,6 +26,20 @@ class ContentCapturingMode(Enum): # Capture content in both spans and events. SPAN_AND_EVENT = 3 + @property + def capture_in_span(self) -> bool: + return self in ( + ContentCapturingMode.SPAN_ONLY, + ContentCapturingMode.SPAN_AND_EVENT, + ) + + @property + def capture_in_event(self) -> bool: + return self in ( + ContentCapturingMode.EVENT_ONLY, + ContentCapturingMode.SPAN_AND_EVENT, + ) + @dataclass() class GenericPart: diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py index 7df35b28..91d499df 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py @@ -17,6 +17,31 @@ logger = logging.getLogger(__name__) +# Map of accepted kwarg string aliases to ContentCapturingMode. Kept lenient +# for backwards compatibility with existing instrumentations that historically +# accepted boolean-ish strings or hyphenated forms. +_CAPTURE_MODE_ALIASES: dict[str, ContentCapturingMode] = { + "span_only": ContentCapturingMode.SPAN_ONLY, + "span-only": ContentCapturingMode.SPAN_ONLY, + "span": ContentCapturingMode.SPAN_ONLY, + "event_only": ContentCapturingMode.EVENT_ONLY, + "event-only": ContentCapturingMode.EVENT_ONLY, + "event": ContentCapturingMode.EVENT_ONLY, + "span_and_event": ContentCapturingMode.SPAN_AND_EVENT, + "span-and-event": ContentCapturingMode.SPAN_AND_EVENT, + "span_and_events": ContentCapturingMode.SPAN_AND_EVENT, + "all": ContentCapturingMode.SPAN_AND_EVENT, + "true": ContentCapturingMode.SPAN_AND_EVENT, + "1": ContentCapturingMode.SPAN_AND_EVENT, + "yes": ContentCapturingMode.SPAN_AND_EVENT, + "no_content": ContentCapturingMode.NO_CONTENT, + "false": ContentCapturingMode.NO_CONTENT, + "0": ContentCapturingMode.NO_CONTENT, + "no": ContentCapturingMode.NO_CONTENT, + "none": ContentCapturingMode.NO_CONTENT, +} + + def get_content_capturing_mode() -> ContentCapturingMode: """Gets ContentCapturingMode from associated envvar, defaulting to NO_CONTENT if unset.""" envvar = os.environ.get( @@ -103,3 +128,30 @@ def default(self, o: Any) -> Any: ) """Should be used by GenAI instrumentations when serializing objects that may contain bytes, datetimes, etc. for GenAI observability.""" + + +def resolve_capture_message_content( + capture_message_content: Any = None, +) -> ContentCapturingMode: + """Resolve the effective content-capture mode for a GenAI instrumentation.""" + if isinstance(capture_message_content, ContentCapturingMode): + return capture_message_content + if isinstance(capture_message_content, bool): + return ( + ContentCapturingMode.SPAN_AND_EVENT + if capture_message_content + else ContentCapturingMode.NO_CONTENT + ) + if capture_message_content is None: + return get_content_capturing_mode() + text = str(capture_message_content).strip().lower() + if not text: + return get_content_capturing_mode() + mode = _CAPTURE_MODE_ALIASES.get(text) + if mode is not None: + return mode + logger.warning( + "Unrecognized capture_message_content value %r; falling back to NO_CONTENT.", + capture_message_content, + ) + return ContentCapturingMode.NO_CONTENT diff --git a/util/opentelemetry-util-genai/tests/test_utils.py b/util/opentelemetry-util-genai/tests/test_utils.py index 0477ee92..11eb073c 100644 --- a/util/opentelemetry-util-genai/tests/test_utils.py +++ b/util/opentelemetry-util-genai/tests/test_utils.py @@ -41,6 +41,7 @@ ) from opentelemetry.util.genai.utils import ( get_content_capturing_mode, + resolve_capture_message_content, should_capture_content_on_spans, should_emit_event, ) @@ -262,6 +263,143 @@ def test_get_content_capturing_mode_with_invalid_envvar_value( self.assertIn("INVALID_VALUE is not a valid option for ", cm.output[0]) +class TestResolveCaptureMessageContent(unittest.TestCase): + """Tests for the ``resolve_capture_message_content`` helper. + + Falls back to ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`` when + the kwarg is unset; never mutates environment variables. + """ + + def test_kwarg_none_uses_env(self): + with patch.dict( + os.environ, + { + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "SPAN_ONLY", + }, + clear=False, + ): + assert ( + resolve_capture_message_content(None) + == ContentCapturingMode.SPAN_ONLY + ) + + def test_kwarg_none_and_empty_env_defaults_to_no_content(self): + with patch.dict( + os.environ, + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""}, + clear=False, + ): + assert ( + resolve_capture_message_content(None) + == ContentCapturingMode.NO_CONTENT + ) + + def test_kwarg_string_resolves(self): + with patch.dict( + os.environ, + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""}, + clear=False, + ): + assert ( + resolve_capture_message_content("span_only") + == ContentCapturingMode.SPAN_ONLY + ) + + def test_kwarg_does_not_mutate_env(self): + with patch.dict( + os.environ, + {"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": ""}, + clear=False, + ): + resolve_capture_message_content("span_only") + assert ( + os.environ[ + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" + ] + == "" + ) + + def test_kwarg_true_maps_to_span_and_event(self): + assert ( + resolve_capture_message_content(True) + == ContentCapturingMode.SPAN_AND_EVENT + ) + + def test_kwarg_false_maps_to_no_content(self): + assert ( + resolve_capture_message_content(False) + == ContentCapturingMode.NO_CONTENT + ) + + def test_kwarg_enum_passthrough(self): + assert ( + resolve_capture_message_content(ContentCapturingMode.EVENT_ONLY) + == ContentCapturingMode.EVENT_ONLY + ) + + def test_kwarg_empty_string_uses_env(self): + with patch.dict( + os.environ, + { + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "EVENT_ONLY", + }, + clear=False, + ): + assert ( + resolve_capture_message_content("") + == ContentCapturingMode.EVENT_ONLY + ) + + def test_unknown_string_falls_back_to_no_content_with_warning(self): + with self.assertLogs(level="WARNING") as cm: + result = resolve_capture_message_content("bogus_value") + assert result == ContentCapturingMode.NO_CONTENT + assert any("Unrecognized" in line for line in cm.output) + + def test_aliases_match_legacy_resolver(self): + cases = { + "span_only": ContentCapturingMode.SPAN_ONLY, + "span-only": ContentCapturingMode.SPAN_ONLY, + "span": ContentCapturingMode.SPAN_ONLY, + "event_only": ContentCapturingMode.EVENT_ONLY, + "event-only": ContentCapturingMode.EVENT_ONLY, + "event": ContentCapturingMode.EVENT_ONLY, + "span_and_event": ContentCapturingMode.SPAN_AND_EVENT, + "span-and-event": ContentCapturingMode.SPAN_AND_EVENT, + "all": ContentCapturingMode.SPAN_AND_EVENT, + "true": ContentCapturingMode.SPAN_AND_EVENT, + "1": ContentCapturingMode.SPAN_AND_EVENT, + "yes": ContentCapturingMode.SPAN_AND_EVENT, + "false": ContentCapturingMode.NO_CONTENT, + "0": ContentCapturingMode.NO_CONTENT, + "no": ContentCapturingMode.NO_CONTENT, + "none": ContentCapturingMode.NO_CONTENT, + "no_content": ContentCapturingMode.NO_CONTENT, + } + for value, expected in cases.items(): + assert ( + resolve_capture_message_content(value) == expected + ), f"value={value!r}" + + +class TestContentCapturingModeProperties(unittest.TestCase): + """Tests for the ``capture_in_span`` / ``capture_in_event`` properties on + :class:`opentelemetry.util.genai.types.ContentCapturingMode`. + """ + + def test_capture_in_span(self): + assert ContentCapturingMode.SPAN_ONLY.capture_in_span is True + assert ContentCapturingMode.SPAN_AND_EVENT.capture_in_span is True + assert ContentCapturingMode.EVENT_ONLY.capture_in_span is False + assert ContentCapturingMode.NO_CONTENT.capture_in_span is False + + def test_capture_in_event(self): + assert ContentCapturingMode.EVENT_ONLY.capture_in_event is True + assert ContentCapturingMode.SPAN_AND_EVENT.capture_in_event is True + assert ContentCapturingMode.SPAN_ONLY.capture_in_event is False + assert ContentCapturingMode.NO_CONTENT.capture_in_event is False + + class TestTelemetryHandler(unittest.TestCase): def setUp(self): self.span_exporter = InMemorySpanExporter() From 61af3869a1aa2deb8a5d8d3a0f864fc5e15f5787 Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Mon, 15 Jun 2026 14:30:02 -0700 Subject: [PATCH 2/2] Add CHANGELOG --- .../.changelog/140.changed | 1 + 1 file changed, 1 insertion(+) create mode 100644 instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/140.changed diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/140.changed b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/140.changed new file mode 100644 index 00000000..ecde5bbe --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/140.changed @@ -0,0 +1 @@ +`opentelemetry-instrumentation-genai-openai-agents`: Move capture_message_content kwarg logic to genai-utils