From 6a23dcd555f86f41b5fb4f182044f4e0f9b69799 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 19 May 2026 09:30:41 +0200 Subject: [PATCH] Add disable_instrumentation() with sticky user-intent semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a public disable_instrumentation() entry point so users can explicitly opt out of Agent Framework telemetry, with a sticky-disable flag that makes the user's intent "leading" — no framework code path (foundry's configure_azure_monitor, configure_otel_providers, enable_instrumentation, enable_sensitive_telemetry, or direct OBSERVABILITY_SETTINGS.enable_* writes) can re-enable instrumentation until the user explicitly clears the disable with enable_instrumentation(force=True) / enable_sensitive_telemetry(force=True). Also addresses the two remaining unresolved review threads on the PR: 1. test_observability_settings_defaults_instrumentation_true pins the new "ENABLE_INSTRUMENTATION defaults to True when env unset" behavior. 2. test_enable_instrumentation_reads_env_sensitive_data restores coverage for the post-import load_dotenv() fallback path. Implementation: - ObservabilitySettings.enable_instrumentation / enable_sensitive_data become properties backed by _enable_*. While _user_disabled is True, the getters return False and the setters drop True writes (defense in depth so third- party writes can't subvert the disable). - Public is_user_disabled read-only property lets integrations (e.g. foundry's configure_azure_monitor) cheaply check the disable state without poking at privates. - enable_instrumentation() and enable_sensitive_telemetry() short-circuit with an info log when disabled; gain a force=True kwarg that clears the disable. - configure_otel_providers() still creates providers / exporters / views so a later force-enable can use them, but logs an info message when called while disabled. - Foundry's FoundryChatClient.configure_azure_monitor and FoundryAgent.configure_azure_monitor early-return when the user has disabled, so Azure Monitor's global providers aren't installed unnecessarily. Tests: 11 new tests covering default-on, env re-read at call time, sticky behavior against each re-enable surface (enable_instrumentation, enable_sensitive_telemetry, configure_otel_providers, direct attribute writes), force=True override, re-arming the disable, and the __all__ export. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/observability.py | 130 ++++++++++++- .../core/tests/core/test_observability.py | 183 ++++++++++++++++++ .../foundry/agent_framework_foundry/_agent.py | 16 +- .../agent_framework_foundry/_chat_client.py | 16 +- python/uv.lock | 2 +- 5 files changed, 338 insertions(+), 9 deletions(-) diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index bef5a71812..022008c05b 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -4,6 +4,7 @@ Commonly used exports: - enable_instrumentation +- disable_instrumentation - enable_sensitive_telemetry - configure_otel_providers - AgentTelemetryLayer @@ -81,6 +82,7 @@ __all__ = [ "configure_otel_providers", "create_metric_views", "create_resource", + "disable_instrumentation", "enable_instrumentation", "enable_sensitive_telemetry", "get_meter", @@ -679,11 +681,18 @@ class ObservabilitySettings: env_file_encoding=env_file_encoding, **kwargs, ) + # Sticky-disable flag, set by `disable_instrumentation()`. When True, this + # singleton refuses to be re-enabled by any subsequent assignment to the + # `enable_instrumentation` / `enable_sensitive_data` properties (including + # direct third-party writes). It can only be cleared by an explicit + # `enable_instrumentation(force=True)` / `enable_sensitive_telemetry(force=True)` + # call, which is the user re-stating their intent. + self._user_disabled: bool = False # `enable_instrumentation` is defaulted to True if not set instrumentation_value = data.get("enable_instrumentation") - self.enable_instrumentation: bool = True if instrumentation_value is None else instrumentation_value - self.enable_sensitive_data: bool = data.get("enable_sensitive_data") or False - if self.enable_sensitive_data and not self.enable_instrumentation: + self._enable_instrumentation: bool = True if instrumentation_value is None else instrumentation_value + self._enable_sensitive_data: bool = data.get("enable_sensitive_data") or False + if self._enable_sensitive_data and not self._enable_instrumentation: logger.warning( "Sensitive data capture is enabled but instrumentation is disabled. " "Sensitive data will not be captured. Please enable instrumentation to capture sensitive data." @@ -695,6 +704,51 @@ class ObservabilitySettings: self.env_file_encoding = env_file_encoding self._executed_setup = False + @property + def enable_instrumentation(self) -> bool: + """Whether instrumentation is enabled. + + Always returns False once ``disable_instrumentation()`` has been called, + regardless of the stored value, until ``enable_instrumentation(force=True)`` + clears the sticky disable. + """ + if self._user_disabled: + return False + return self._enable_instrumentation + + @enable_instrumentation.setter + def enable_instrumentation(self, value: bool) -> None: + if self._user_disabled and value: + # Defense in depth: a third-party (or internal) write of True is + # silently dropped while the user-disabled flag is set, so the + # sticky disable cannot be circumvented by direct attribute writes. + logger.debug( + "Ignoring enable_instrumentation=True assignment: instrumentation was explicitly disabled via " + "disable_instrumentation(). Call enable_instrumentation(force=True) to clear the disable." + ) + return + self._enable_instrumentation = value + + @property + def enable_sensitive_data(self) -> bool: + """Whether sensitive-data capture is enabled. + + Always returns False once ``disable_instrumentation()`` has been called. + """ + if self._user_disabled: + return False + return self._enable_sensitive_data + + @enable_sensitive_data.setter + def enable_sensitive_data(self, value: bool) -> None: + if self._user_disabled and value: + logger.debug( + "Ignoring enable_sensitive_data=True assignment: instrumentation was explicitly disabled via " + "disable_instrumentation(). Call enable_sensitive_telemetry(force=True) to clear the disable." + ) + return + self._enable_sensitive_data = value + @property def ENABLED(self) -> bool: """Check if model diagnostics are enabled. @@ -716,6 +770,17 @@ class ObservabilitySettings: """Check if the setup has been executed.""" return self._executed_setup + @property + def is_user_disabled(self) -> bool: + """Whether ``disable_instrumentation()`` has been called and the disable is still in effect. + + Integrations that perform telemetry setup as a side-effect (e.g. provisioning Azure Monitor + providers from a Foundry project's connection string) should consult this flag before doing + their setup work, so the user's explicit opt-out is respected end-to-end and not just at the + framework's span-emission boundary. + """ + return self._user_disabled + def _configure( self, *, @@ -961,7 +1026,7 @@ def _read_int_env(name: str, *, default: int | None = None) -> int | None: return default -def enable_sensitive_telemetry() -> None: +def enable_sensitive_telemetry(*, force: bool = False) -> None: """Enable capture of sensitive data in telemetry for your application. Instrumentation is enabled by default; this method exists to opt-in to capturing @@ -971,17 +1036,57 @@ def enable_sensitive_telemetry() -> None: instrumentation is enabled (in case it was explicitly disabled via the ENABLE_INSTRUMENTATION environment variable). + Keyword Args: + 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. + Warning: Sensitive events should only be enabled on test and development environments. """ global OBSERVABILITY_SETTINGS + if OBSERVABILITY_SETTINGS._user_disabled and not force: # type: ignore[reportPrivateUsage] + logger.info( + "enable_sensitive_telemetry() ignored: instrumentation was explicitly disabled via " + "disable_instrumentation(). Pass force=True to re-enable." + ) + return + if force: + OBSERVABILITY_SETTINGS._user_disabled = False # type: ignore[reportPrivateUsage] OBSERVABILITY_SETTINGS.enable_instrumentation = True OBSERVABILITY_SETTINGS.enable_sensitive_data = True +def disable_instrumentation() -> None: + """Explicitly disable Agent Framework instrumentation for this process. + + The disable is **sticky**: subsequent attempts by framework auto-setup paths, + library integrations, ``enable_instrumentation()``, ``enable_sensitive_telemetry()``, + ``configure_otel_providers()``, or direct writes to + ``OBSERVABILITY_SETTINGS.enable_instrumentation`` are ignored and no spans, metrics, + or logs are emitted by Agent Framework code paths. + + To override the disable later, call ``enable_instrumentation(force=True)`` or + ``enable_sensitive_telemetry(force=True)``. This makes the user's intent to opt out + win against framework code that would otherwise re-enable instrumentation + automatically. + + Note: + Disabling does not tear down already-configured OpenTelemetry providers, + exporters, or in-flight spans; it gates future captures by Agent Framework + instrumentation only. To stop emitting telemetry from third-party + instrumentations as well, configure them separately. + """ + global OBSERVABILITY_SETTINGS + OBSERVABILITY_SETTINGS._user_disabled = True # type: ignore[reportPrivateUsage] + OBSERVABILITY_SETTINGS._enable_instrumentation = False # type: ignore[reportPrivateUsage] + OBSERVABILITY_SETTINGS._enable_sensitive_data = False # type: ignore[reportPrivateUsage] + + def enable_instrumentation( *, enable_sensitive_data: bool | None = None, + force: bool = False, ) -> None: """Enable instrumentation for Microsoft Agent Framework. @@ -993,8 +1098,19 @@ def enable_instrumentation( Keyword Args: enable_sensitive_data: Enable OpenTelemetry sensitive events. Overrides the environment variable ENABLE_SENSITIVE_DATA if set. Default is None. + 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. """ global OBSERVABILITY_SETTINGS + if OBSERVABILITY_SETTINGS._user_disabled and not force: # type: ignore[reportPrivateUsage] + logger.info( + "enable_instrumentation() ignored: instrumentation was explicitly disabled via " + "disable_instrumentation(). Pass force=True to re-enable." + ) + return + if force: + OBSERVABILITY_SETTINGS._user_disabled = False # type: ignore[reportPrivateUsage] OBSERVABILITY_SETTINGS.enable_instrumentation = True if enable_sensitive_data is not None: OBSERVABILITY_SETTINGS.enable_sensitive_data = enable_sensitive_data @@ -1125,6 +1241,12 @@ def configure_otel_providers( - https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/ """ global OBSERVABILITY_SETTINGS + if OBSERVABILITY_SETTINGS._user_disabled: # type: ignore[reportPrivateUsage] + logger.info( + "configure_otel_providers(): instrumentation was explicitly disabled via " + "disable_instrumentation(); providers and exporters will still be configured but " + "Agent Framework will emit no telemetry until enable_instrumentation(force=True) is called." + ) if env_file_path: # Build kwargs, excluding None values settings_kwargs: dict[str, Any] = { diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index f44223757c..6185bccd44 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -1387,6 +1387,189 @@ def test_configure_otel_providers_explicit_console_exporters_overrides_env(monke assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False +# region Test default-on instrumentation + + +def test_observability_settings_defaults_instrumentation_true(monkeypatch): + """ENABLE_INSTRUMENTATION unset → ObservabilitySettings defaults to True.""" + from agent_framework.observability import ObservabilitySettings + + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + settings = ObservabilitySettings() + assert settings.enable_instrumentation is True + + +def test_enable_instrumentation_reads_env_sensitive_data(monkeypatch): + """No-arg enable_instrumentation() re-reads ENABLE_SENSITIVE_DATA from env at call time. + + Covers the fallback branch where the env var is set AFTER import (e.g. via load_dotenv()). + """ + import importlib + + monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false") + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + # Simulate load_dotenv() setting the env var after import + monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true") + observability.enable_instrumentation() + + assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True + + +# region Test disable_instrumentation sticky behavior + + +def test_disable_instrumentation_flips_settings_off(monkeypatch): + """disable_instrumentation() immediately turns instrumentation and sensitive data off.""" + import importlib + + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true") + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + observability.enable_sensitive_telemetry() + assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True + assert observability.OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED is True + + observability.disable_instrumentation() + assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + assert observability.OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED is False + assert observability.OBSERVABILITY_SETTINGS.ENABLED is False + + +def test_disable_instrumentation_is_sticky_against_enable_instrumentation(monkeypatch): + """Sticky disable: enable_instrumentation() without force is a no-op after disable.""" + import importlib + + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + observability.disable_instrumentation() + observability.enable_instrumentation(enable_sensitive_data=True) + assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + + +def test_disable_instrumentation_is_sticky_against_enable_sensitive_telemetry(monkeypatch): + """Sticky disable: enable_sensitive_telemetry() without force is a no-op after disable.""" + import importlib + + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + observability.disable_instrumentation() + observability.enable_sensitive_telemetry() + assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + + +def test_disable_instrumentation_is_sticky_against_configure_otel_providers(monkeypatch): + """Sticky disable: configure_otel_providers() does not flip instrumentation back on.""" + import importlib + + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + observability.disable_instrumentation() + with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"): + observability.configure_otel_providers(enable_sensitive_data=True) + assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + + +def test_disable_instrumentation_intercepts_direct_attribute_writes(monkeypatch): + """Sticky disable: direct OBSERVABILITY_SETTINGS.enable_instrumentation = True is intercepted.""" + import importlib + + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + observability.disable_instrumentation() + observability.OBSERVABILITY_SETTINGS.enable_instrumentation = True + observability.OBSERVABILITY_SETTINGS.enable_sensitive_data = True + assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + + +def test_enable_instrumentation_force_clears_disable(monkeypatch): + """enable_instrumentation(force=True) clears the sticky disable.""" + import importlib + + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + observability.disable_instrumentation() + observability.enable_instrumentation(force=True, enable_sensitive_data=True) + assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True + + +def test_enable_sensitive_telemetry_force_clears_disable(monkeypatch): + """enable_sensitive_telemetry(force=True) clears the sticky disable.""" + import importlib + + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + observability.disable_instrumentation() + observability.enable_sensitive_telemetry(force=True) + assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True + + +def test_disable_instrumentation_persists_after_force_until_redisabled(monkeypatch): + """After force-enable then disable again, the sticky disable is re-armed.""" + import importlib + + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + + observability = importlib.import_module("agent_framework.observability") + importlib.reload(observability) + + observability.disable_instrumentation() + observability.enable_instrumentation(force=True) + assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True + + observability.disable_instrumentation() + observability.enable_instrumentation() + assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False + + +def test_disable_instrumentation_in_all(monkeypatch): + """disable_instrumentation must be re-exported from the module's __all__.""" + import agent_framework.observability as observability + + assert "disable_instrumentation" in observability.__all__ + assert callable(observability.disable_instrumentation) + + # region Test _to_otel_part content types diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 8b737694e3..056d3977af 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -793,8 +793,22 @@ class RawFoundryAgent( # type: ignore[misc] Raises: ImportError: If azure-monitor-opentelemetry-exporter is not installed. """ + from agent_framework.observability import ( + OBSERVABILITY_SETTINGS, + create_metric_views, + create_resource, + enable_instrumentation, + ) from azure.core.exceptions import ResourceNotFoundError + if OBSERVABILITY_SETTINGS.is_user_disabled: + logger.info( + "FoundryAgent.configure_azure_monitor(): Skipping setup because instrumentation was " + "explicitly disabled via disable_instrumentation(). Call enable_instrumentation(force=True) " + "to re-enable, then re-invoke configure_azure_monitor()." + ) + return + client = self.client if not isinstance(client, RawFoundryAgentChatClient): raise TypeError("configure_azure_monitor requires a RawFoundryAgentChatClient-based client.") @@ -817,8 +831,6 @@ class RawFoundryAgent( # type: ignore[misc] "Install it with: pip install azure-monitor-opentelemetry" ) from exc - from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation - if "resource" not in kwargs: kwargs["resource"] = create_resource() diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 614efcad15..7f8e033036 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -271,8 +271,22 @@ class RawFoundryChatClient( # type: ignore[misc] Raises: ImportError: If azure-monitor-opentelemetry-exporter is not installed. """ + from agent_framework.observability import ( + OBSERVABILITY_SETTINGS, + create_metric_views, + create_resource, + enable_instrumentation, + ) from azure.core.exceptions import ResourceNotFoundError + if OBSERVABILITY_SETTINGS.is_user_disabled: + logger.info( + "FoundryChatClient.configure_azure_monitor(): Skipping setup because instrumentation was " + "explicitly disabled via disable_instrumentation(). Call enable_instrumentation(force=True) " + "to re-enable, then re-invoke configure_azure_monitor()." + ) + return + try: conn_string = await self.project_client.telemetry.get_application_insights_connection_string() except ResourceNotFoundError: @@ -291,8 +305,6 @@ class RawFoundryChatClient( # type: ignore[misc] "Install it with: pip install azure-monitor-opentelemetry" ) from exc - from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation - if "resource" not in kwargs: kwargs["resource"] = create_resource() diff --git a/python/uv.lock b/python/uv.lock index cd8ee8a0ca..05da4c1be3 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -602,7 +602,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" }, + { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" }, ] [[package]]