diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 9ecc90d06c8..fc911223ef9 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -1556,6 +1556,7 @@ def disable_instrumentation() -> None: def enable_instrumentation( *, enable_sensitive_data: bool | None = None, + enable_message_events: bool | None = None, force: bool = False, ) -> None: """Enable instrumentation for Microsoft Agent Framework. @@ -1568,9 +1569,17 @@ def enable_instrumentation( Keyword Args: enable_sensitive_data: Enable OpenTelemetry sensitive events. Overrides the environment variable ENABLE_SENSITIVE_DATA if set. Default is None. + enable_message_events: Emit the baseline v1.36.0 GenAI message events for + model invocation when sensitive data capture is enabled. Explicit values + override the current setting, including values from ENABLE_MESSAGE_EVENTS. + Default is None, which preserves the current setting without re-reading + the environment. Does not affect experimental message span attributes. force: When True, clears any sticky disable previously set by ``disable_instrumentation()`` before enabling. Without it, calls are no-ops if instrumentation has been explicitly disabled. + + Note: + This function does not configure or replace OpenTelemetry providers or exporters. """ global OBSERVABILITY_SETTINGS if OBSERVABILITY_SETTINGS._user_disabled and not force: # type: ignore[reportPrivateUsage] @@ -1587,6 +1596,8 @@ def enable_instrumentation( else: # Re-read from current environment in case env vars were set after import (e.g. load_dotenv()) OBSERVABILITY_SETTINGS.enable_sensitive_data = _read_bool_env("ENABLE_SENSITIVE_DATA") + if enable_message_events is not None: + OBSERVABILITY_SETTINGS.enable_message_events = enable_message_events def configure_otel_providers( diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 0dcafce37f8..fb4a5b1d38c 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -1914,6 +1914,137 @@ def test_enable_instrumentation_explicit_param_overrides_env(monkeypatch): assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False +@pytest.mark.parametrize("message_events", [False, True]) +def test_enable_instrumentation_message_events_overrides_env(monkeypatch, message_events: bool) -> None: + import agent_framework.observability as observability + + monkeypatch.setenv("ENABLE_MESSAGE_EVENTS", str(not message_events)) + monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false") + settings = observability.ObservabilitySettings(enable_instrumentation=False) + monkeypatch.setattr(observability, "OBSERVABILITY_SETTINGS", settings) + assert settings.enable_message_events is not message_events + + observability.enable_instrumentation(enable_message_events=message_events) + + assert settings.enable_instrumentation is True + assert settings.enable_message_events is message_events + assert settings.enable_sensitive_data is False + + +@pytest.mark.parametrize("message_events", [False, True]) +@pytest.mark.parametrize("source", ["environment", "providers", "instrumentation"]) +def test_enable_instrumentation_preserves_message_events(monkeypatch, message_events: bool, source: str) -> None: + import agent_framework.observability as observability + + monkeypatch.setenv("ENABLE_MESSAGE_EVENTS", str(message_events if source == "environment" else not message_events)) + settings = observability.ObservabilitySettings() + monkeypatch.setattr(observability, "OBSERVABILITY_SETTINGS", settings) + if source == "providers": + with patch.object(settings, "_configure"): + observability.configure_otel_providers(enable_message_events=message_events) + elif source == "instrumentation": + observability.enable_instrumentation(enable_message_events=message_events) + + for env_value in [str(not message_events), None]: + if env_value is None: + monkeypatch.delenv("ENABLE_MESSAGE_EVENTS") + else: + monkeypatch.setenv("ENABLE_MESSAGE_EVENTS", env_value) + observability.enable_instrumentation() + assert settings.enable_message_events is message_events + observability.enable_instrumentation(enable_message_events=None) + assert settings.enable_message_events is message_events + + +@pytest.mark.parametrize("current_value", [False, True]) +@pytest.mark.parametrize("message_events", [False, True, None]) +@pytest.mark.parametrize("force", [False, True]) +def test_enable_instrumentation_message_events_respects_sticky_disable( + monkeypatch, current_value: bool, message_events: bool | None, force: bool +) -> None: + import agent_framework.observability as observability + + settings = observability.ObservabilitySettings(enable_message_events=current_value) + monkeypatch.setattr(observability, "OBSERVABILITY_SETTINGS", settings) + observability.disable_instrumentation() + + observability.enable_instrumentation(enable_message_events=message_events, enable_sensitive_data=True, force=force) + + assert settings.enable_instrumentation is force + assert settings.enable_sensitive_data is force + assert settings.is_user_disabled is not force + expected = message_events if force and message_events is not None else current_value + assert settings.enable_message_events is expected + + +@pytest.mark.parametrize("is_setup", [False, True]) +def test_enable_instrumentation_message_events_preserves_providers( + monkeypatch, span_exporter, log_record_exporter, is_setup: bool +) -> None: + from opentelemetry import metrics, trace + from opentelemetry._logs import get_logger_provider + + import agent_framework.observability as observability + + settings = observability.OBSERVABILITY_SETTINGS + monkeypatch.setattr(settings, "enable_console_exporters", True) + monkeypatch.setattr(settings, "_executed_setup", is_setup) + providers = (trace.get_tracer_provider(), get_logger_provider(), metrics.get_meter_provider()) + with patch.object(settings, "_configure") as configure: + for message_events in [False, True, None]: + observability.enable_instrumentation(enable_message_events=message_events) + assert settings.is_setup is is_setup + assert settings.enable_console_exporters is True + assert trace.get_tracer_provider() is providers[0] + assert get_logger_provider() is providers[1] + assert metrics.get_meter_provider() is providers[2] + configure.assert_not_called() + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("message_events", [False, True]) +@pytest.mark.parametrize("enable_sensitive_data", [False, True], indirect=True) +@pytest.mark.parametrize("semconv", ["", "gen_ai_latest_experimental"]) +async def test_enable_instrumentation_controls_message_event_emission( + mock_chat_client, + span_exporter: InMemorySpanExporter, + log_record_exporter, + enable_sensitive_data: bool, + stream: bool, + message_events: bool, + semconv: str, +) -> None: + import agent_framework.observability as observability + + observability.OBSERVABILITY_SETTINGS.otel_semconv_stability_opt_in = semconv + observability.enable_instrumentation(enable_message_events=message_events) + client = mock_chat_client() + messages = [Message("user", ["Test message"])] + if stream: + response_stream = client.get_response(messages=messages, options={"model": "Test"}, stream=True) + async for _ in response_stream: + pass + await response_stream.get_final_response() + else: + await client.get_response(messages=messages, options={"model": "Test"}) + + records = [record.log_record for record in log_record_exporter.get_finished_logs()] + expected_events = [] + if message_events and enable_sensitive_data: + expected_events = [OtelAttr.USER_MESSAGE.value] + if stream: + expected_events.append(OtelAttr.CHOICE.value) + assert [record.event_name for record in records] == expected_events + if records: + assert records[0].body == {"content": "Test message"} + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + attributes = spans[0].attributes or {} + assert (OtelAttr.INPUT_MESSAGES in attributes) is (enable_sensitive_data and bool(semconv)) + assert (OtelAttr.OUTPUT_MESSAGES in attributes) is (enable_sensitive_data and bool(semconv)) + + def test_enable_instrumentation_does_not_touch_console_exporters(monkeypatch): """Test enable_instrumentation does not modify enable_console_exporters (it is an exporter concern).""" import importlib diff --git a/python/samples/02-agents/observability/README.md b/python/samples/02-agents/observability/README.md index 822006b17dd..e69af64f684 100644 --- a/python/samples/02-agents/observability/README.md +++ b/python/samples/02-agents/observability/README.md @@ -88,6 +88,18 @@ from microsoft.opentelemetry import use_microsoft_opentelemetry use_microsoft_opentelemetry(enable_azure_monitor=True) ``` +To disable Agent Framework's baseline GenAI message events without changing providers or exporters configured by a third party, use the instrumentation-only API: + +```python +from agent_framework.observability import enable_instrumentation + +enable_instrumentation(enable_message_events=False) +``` + +An explicit `True` or `False` overrides the current message-event setting, including a value read from `ENABLE_MESSAGE_EVENTS`. Omitting the argument or passing `None` preserves the current setting without re-reading the environment, including settings established by `configure_otel_providers()` or an earlier explicit call. + +This flag controls baseline v1.36.0 events such as `gen_ai.user.message` and `gen_ai.choice`; it does not disable experimental message span attributes, spans, or metrics. Message events still require sensitive-data capture to be enabled separately. Existing `enable_sensitive_data` behavior is unchanged, and [sticky disable](#disabling-instrumentation) still requires `force=True` to re-enable instrumentation. + ```python from azure.monitor.opentelemetry import configure_azure_monitor from agent_framework.observability import create_resource, enable_sensitive_telemetry