From 1848a7b95250b2f2712e30180741b0dec74aea46 Mon Sep 17 00:00:00 2001 From: droideronline Date: Fri, 20 Feb 2026 13:35:59 +0530 Subject: [PATCH 1/3] fix: re-read ENABLE_SENSITIVE_DATA and VS_CODE_EXTENSION_PORT from env at call time When load_dotenv() runs after module import, the module-level OBSERVABILITY_SETTINGS singleton has stale cached values. Both configure_otel_providers() and enable_instrumentation() now fall back to os.environ when their parameters are None. Added _env_bool() helper and 5 unit tests. Fixes #4119 --- .../core/agent_framework/observability.py | 23 ++- .../core/tests/core/test_observability.py | 134 ++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 8f581a605d..0c7f5cc735 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -884,6 +884,14 @@ def get_meter( OBSERVABILITY_SETTINGS: ObservabilitySettings = ObservabilitySettings() +def _env_bool(name: str) -> bool: + """Read a boolean from the current environment. + + Accepts the same truthy strings as ``_coerce_value`` in ``_settings.py``. + """ + return os.getenv(name, "").lower() in ("true", "1", "yes", "on") + + def enable_instrumentation( *, enable_sensitive_data: bool | None = None, @@ -900,11 +908,14 @@ def enable_instrumentation( Keyword Args: enable_sensitive_data: Enable OpenTelemetry sensitive events. Overrides the environment variable ENABLE_SENSITIVE_DATA if set. Default is None. + When not provided, falls back to the ENABLE_SENSITIVE_DATA environment variable. """ global OBSERVABILITY_SETTINGS OBSERVABILITY_SETTINGS.enable_instrumentation = True if enable_sensitive_data is not None: OBSERVABILITY_SETTINGS.enable_sensitive_data = enable_sensitive_data + elif _env_bool("ENABLE_SENSITIVE_DATA"): + OBSERVABILITY_SETTINGS.enable_sensitive_data = True def configure_otel_providers( @@ -1041,12 +1052,22 @@ def configure_otel_providers( OBSERVABILITY_SETTINGS = ObservabilitySettings(**settings_kwargs) else: - # Update the observability settings with the provided values + # Update the observability settings with the provided values. + # When parameters are not explicitly passed, re-read from the environment + # because load_dotenv() may have populated os.environ *after* the module-level + # ``OBSERVABILITY_SETTINGS = ObservabilitySettings()`` was constructed at import time, + # leaving the cached values stale. OBSERVABILITY_SETTINGS.enable_instrumentation = True if enable_sensitive_data is not None: OBSERVABILITY_SETTINGS.enable_sensitive_data = enable_sensitive_data + elif _env_bool("ENABLE_SENSITIVE_DATA"): + OBSERVABILITY_SETTINGS.enable_sensitive_data = True if vs_code_extension_port is not None: OBSERVABILITY_SETTINGS.vs_code_extension_port = vs_code_extension_port + else: + env_port = os.getenv("VS_CODE_EXTENSION_PORT") + if env_port: + OBSERVABILITY_SETTINGS.vs_code_extension_port = int(env_port) OBSERVABILITY_SETTINGS._configure( # type: ignore[reportPrivateUsage] additional_exporters=exporters, diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index fccaf2f9f1..41d1b08ba3 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -2597,3 +2597,137 @@ async def test_agent_no_instructions_in_default_or_options( span = spans[0] assert OtelAttr.SYSTEM_INSTRUCTIONS not in span.attributes + + +# region Test env fallback for enable_sensitive_data after late load_dotenv + + +def test_configure_otel_providers_reads_sensitive_data_from_env(monkeypatch): + """configure_otel_providers() should pick up ENABLE_SENSITIVE_DATA from os.environ + even when OBSERVABILITY_SETTINGS was constructed before the env var was set + (simulates the load_dotenv-after-import pattern used by all samples). + """ + import importlib + + import agent_framework.observability as observability + + # 1. Start with a clean environment — no instrumentation env vars + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + # Clear OTLP endpoints to prevent _configure from trying to create exporters + for key in [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ]: + monkeypatch.delenv(key, raising=False) + + # 2. Reload module — this re-runs ``OBSERVABILITY_SETTINGS = ObservabilitySettings()`` + # with neither env var set, so both cached values are False. + importlib.reload(observability) + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + + # 3. Simulate load_dotenv() populating the env (happens after import in real samples) + monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true") + + # 4. Call configure_otel_providers() without explicit enable_sensitive_data param. + # Before the fix this left enable_sensitive_data as False. + observability.configure_otel_providers() + + 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 + + +def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch): + """An explicit enable_sensitive_data=False should override the env var.""" + import importlib + + import agent_framework.observability as observability + + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + for key in [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ]: + monkeypatch.delenv(key, raising=False) + + importlib.reload(observability) + + # Env says true, but explicit param says False — param wins. + monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true") + observability.configure_otel_providers(enable_sensitive_data=False) + + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + + +def test_enable_instrumentation_reads_sensitive_data_from_env(monkeypatch): + """enable_instrumentation() should pick up ENABLE_SENSITIVE_DATA from os.environ + when called without an explicit enable_sensitive_data parameter. + """ + import importlib + + import agent_framework.observability as observability + + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + + importlib.reload(observability) + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + + # Simulate load_dotenv() 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 + + +def test_enable_instrumentation_explicit_false_overrides_env(monkeypatch): + """enable_instrumentation(enable_sensitive_data=False) should override env var.""" + import importlib + + import agent_framework.observability as observability + + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + + importlib.reload(observability) + monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true") + + observability.enable_instrumentation(enable_sensitive_data=False) + + assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + + +def test_configure_otel_providers_reads_vs_code_port_from_env(monkeypatch): + """configure_otel_providers() should pick up VS_CODE_EXTENSION_PORT from os.environ.""" + import importlib + + import agent_framework.observability as observability + + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False) + for key in [ + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + ]: + monkeypatch.delenv(key, raising=False) + + importlib.reload(observability) + assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port is None + + monkeypatch.setenv("VS_CODE_EXTENSION_PORT", "4317") + # Mock _configure to avoid requiring OTLP exporter packages + monkeypatch.setattr(observability.ObservabilitySettings, "_configure", lambda self, **kwargs: None) + observability.configure_otel_providers() + + assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317 \ No newline at end of file From 4f56deddba9eeb4013c8b61664248b5b7504304f Mon Sep 17 00:00:00 2001 From: droideronline Date: Fri, 20 Feb 2026 13:57:21 +0530 Subject: [PATCH 2/3] fix: address review comments - safe int parsing and mock _configure in tests - VS_CODE_EXTENSION_PORT: strip whitespace and wrap int() in try/except with a clear ValueError mentioning the env var name. - Tests: mock _configure to no-op in configure_otel_providers tests to prevent global OTel provider state leakage across test runs. --- python/packages/core/agent_framework/observability.py | 7 ++++++- python/packages/core/tests/core/test_observability.py | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 0c7f5cc735..e5531d4e55 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -1067,7 +1067,12 @@ def configure_otel_providers( else: env_port = os.getenv("VS_CODE_EXTENSION_PORT") if env_port: - OBSERVABILITY_SETTINGS.vs_code_extension_port = int(env_port) + env_port_stripped = env_port.strip() + if env_port_stripped: + try: + OBSERVABILITY_SETTINGS.vs_code_extension_port = int(env_port_stripped) + except ValueError as exc: + raise ValueError(f"Invalid integer value for VS_CODE_EXTENSION_PORT: {env_port!r}") from exc OBSERVABILITY_SETTINGS._configure( # type: ignore[reportPrivateUsage] additional_exporters=exporters, diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 41d1b08ba3..b789d9f532 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -2633,6 +2633,8 @@ def test_configure_otel_providers_reads_sensitive_data_from_env(monkeypatch): # 4. Call configure_otel_providers() without explicit enable_sensitive_data param. # Before the fix this left enable_sensitive_data as False. + # Mock _configure to avoid mutating global OTel providers. + monkeypatch.setattr(observability.ObservabilitySettings, "_configure", lambda self, **kwargs: None) observability.configure_otel_providers() assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True @@ -2659,6 +2661,8 @@ def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch): importlib.reload(observability) # Env says true, but explicit param says False — param wins. + # Mock _configure to avoid mutating global OTel providers. + monkeypatch.setattr(observability.ObservabilitySettings, "_configure", lambda self, **kwargs: None) monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true") observability.configure_otel_providers(enable_sensitive_data=False) @@ -2730,4 +2734,4 @@ def test_configure_otel_providers_reads_vs_code_port_from_env(monkeypatch): monkeypatch.setattr(observability.ObservabilitySettings, "_configure", lambda self, **kwargs: None) observability.configure_otel_providers() - assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317 \ No newline at end of file + assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317 From 03be169e17f2ba2779a09ab27b3c76707b66e97a Mon Sep 17 00:00:00 2001 From: droideronline Date: Sat, 21 Feb 2026 13:15:52 +0530 Subject: [PATCH 3/3] fix: defer ObservabilitySettings creation to call time instead of import time Move OBSERVABILITY_SETTINGS from eagerly-created singleton at import time to None, deferring creation into configure_otel_providers() and enable_instrumentation(). This ensures environment variables populated by load_dotenv() after import are correctly picked up. - OBSERVABILITY_SETTINGS starts as None (no import-time construction) - configure_otel_providers() always creates a fresh ObservabilitySettings with enable_instrumentation=True, reading current os.environ via load_settings() - enable_instrumentation() creates ObservabilitySettings on first call if still None - All consumers add None guards before accessing OBSERVABILITY_SETTINGS - Removed _env_bool() helper (no longer needed) - Updated tests to expect None after module reload Fixes #4119 --- .../packages/core/agent_framework/_tools.py | 11 +- .../_workflows/_workflow_context.py | 2 +- .../core/agent_framework/observability.py | 102 ++++++------------ .../core/tests/core/test_observability.py | 27 +++-- .../devui/agent_framework_devui/_executor.py | 2 +- 5 files changed, 60 insertions(+), 84 deletions(-) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 64b52c0a12..842779d86d 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -149,7 +149,7 @@ def _default_histogram() -> Histogram: """ from .observability import OBSERVABILITY_SETTINGS # local import to avoid circulars - if not OBSERVABILITY_SETTINGS.ENABLED: # type: ignore[name-defined] + if OBSERVABILITY_SETTINGS is None or not OBSERVABILITY_SETTINGS.ENABLED: # type: ignore[name-defined] return NoOpHistogram( name=OtelAttr.MEASUREMENT_FUNCTION_INVOCATION_DURATION, unit=OtelAttr.DURATION_UNIT, @@ -476,7 +476,8 @@ async def invoke( kwargs.update(original_kwargs) else: kwargs = original_kwargs - if not OBSERVABILITY_SETTINGS.ENABLED: # type: ignore[name-defined] + settings = OBSERVABILITY_SETTINGS + if settings is None or not settings.ENABLED: # type: ignore[name-defined] logger.info(f"Function name: {self.name}") logger.debug(f"Function arguments: {kwargs}") res = self.__call__(**kwargs) @@ -506,7 +507,7 @@ async def invoke( "response_format", } } - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] + if settings.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] attributes.update({ OtelAttr.TOOL_ARGUMENTS: ( json.dumps(serializable_kwargs, default=str, ensure_ascii=False) if serializable_kwargs else "None" @@ -515,7 +516,7 @@ async def invoke( with get_function_span(attributes=attributes) as span: attributes[OtelAttr.MEASUREMENT_FUNCTION_TAG_NAME] = self.name logger.info(f"Function name: {self.name}") - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] + if settings.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] logger.debug(f"Function arguments: {serializable_kwargs}") start_time_stamp = perf_counter() end_time_stamp: float | None = None @@ -536,7 +537,7 @@ async def invoke( logger.warning(f"Function {self.name}: result parser failed, falling back to str().") parsed = str(result) logger.info(f"Function {self.name} succeeded.") - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] + if settings.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] span.set_attribute(OtelAttr.TOOL_RESULT, parsed) logger.debug(f"Function result: {parsed}") return parsed diff --git a/python/packages/core/agent_framework/_workflows/_workflow_context.py b/python/packages/core/agent_framework/_workflows/_workflow_context.py index 51add07a5c..0e06524400 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_context.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_context.py @@ -327,7 +327,7 @@ async def send_message(self, message: OutT, target_id: str | None = None) -> Non self._sent_messages.append(message) # Inject current trace context if tracing enabled - if OBSERVABILITY_SETTINGS.ENABLED and span and span.is_recording(): # type: ignore[name-defined] + if OBSERVABILITY_SETTINGS is not None and OBSERVABILITY_SETTINGS.ENABLED and span and span.is_recording(): # type: ignore[name-defined] trace_context: dict[str, str] = {} inject(trace_context) # Inject current trace context for message propagation diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index e5531d4e55..f2fbc5762c 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -880,16 +880,7 @@ def get_meter( return metrics.get_meter(name=name, version=version, schema_url=schema_url) -global OBSERVABILITY_SETTINGS -OBSERVABILITY_SETTINGS: ObservabilitySettings = ObservabilitySettings() - - -def _env_bool(name: str) -> bool: - """Read a boolean from the current environment. - - Accepts the same truthy strings as ``_coerce_value`` in ``_settings.py``. - """ - return os.getenv(name, "").lower() in ("true", "1", "yes", "on") +OBSERVABILITY_SETTINGS: ObservabilitySettings | None = None def enable_instrumentation( @@ -911,11 +902,11 @@ def enable_instrumentation( When not provided, falls back to the ENABLE_SENSITIVE_DATA environment variable. """ global OBSERVABILITY_SETTINGS + if OBSERVABILITY_SETTINGS is None: + OBSERVABILITY_SETTINGS = ObservabilitySettings() OBSERVABILITY_SETTINGS.enable_instrumentation = True if enable_sensitive_data is not None: OBSERVABILITY_SETTINGS.enable_sensitive_data = enable_sensitive_data - elif _env_bool("ENABLE_SENSITIVE_DATA"): - OBSERVABILITY_SETTINGS.enable_sensitive_data = True def configure_otel_providers( @@ -1037,42 +1028,22 @@ def configure_otel_providers( - https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/ """ global OBSERVABILITY_SETTINGS - if env_file_path: - # Build kwargs, excluding None values - settings_kwargs: dict[str, Any] = { - "enable_instrumentation": True, - "env_file_path": env_file_path, - } - if env_file_encoding is not None: - settings_kwargs["env_file_encoding"] = env_file_encoding - if enable_sensitive_data is not None: - settings_kwargs["enable_sensitive_data"] = enable_sensitive_data - if vs_code_extension_port is not None: - settings_kwargs["vs_code_extension_port"] = vs_code_extension_port - - OBSERVABILITY_SETTINGS = ObservabilitySettings(**settings_kwargs) - else: - # Update the observability settings with the provided values. - # When parameters are not explicitly passed, re-read from the environment - # because load_dotenv() may have populated os.environ *after* the module-level - # ``OBSERVABILITY_SETTINGS = ObservabilitySettings()`` was constructed at import time, - # leaving the cached values stale. - OBSERVABILITY_SETTINGS.enable_instrumentation = True - if enable_sensitive_data is not None: - OBSERVABILITY_SETTINGS.enable_sensitive_data = enable_sensitive_data - elif _env_bool("ENABLE_SENSITIVE_DATA"): - OBSERVABILITY_SETTINGS.enable_sensitive_data = True - if vs_code_extension_port is not None: - OBSERVABILITY_SETTINGS.vs_code_extension_port = vs_code_extension_port - else: - env_port = os.getenv("VS_CODE_EXTENSION_PORT") - if env_port: - env_port_stripped = env_port.strip() - if env_port_stripped: - try: - OBSERVABILITY_SETTINGS.vs_code_extension_port = int(env_port_stripped) - except ValueError as exc: - raise ValueError(f"Invalid integer value for VS_CODE_EXTENSION_PORT: {env_port!r}") from exc + # Build kwargs for a fresh ObservabilitySettings, excluding None values. + # Creating the settings here (instead of at import time) ensures that + # environment variables populated by the caller's load_dotenv() are picked up. + settings_kwargs: dict[str, Any] = { + "enable_instrumentation": True, + } + if env_file_path is not None: + settings_kwargs["env_file_path"] = env_file_path + if env_file_encoding is not None: + settings_kwargs["env_file_encoding"] = env_file_encoding + if enable_sensitive_data is not None: + settings_kwargs["enable_sensitive_data"] = enable_sensitive_data + if vs_code_extension_port is not None: + settings_kwargs["vs_code_extension_port"] = vs_code_extension_port + + OBSERVABILITY_SETTINGS = ObservabilitySettings(**settings_kwargs) OBSERVABILITY_SETTINGS._configure( # type: ignore[reportPrivateUsage] additional_exporters=exporters, @@ -1158,10 +1129,10 @@ def get_response( **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: """Trace chat responses with OpenTelemetry spans and metrics.""" - global OBSERVABILITY_SETTINGS + settings = OBSERVABILITY_SETTINGS super_get_response = super().get_response # type: ignore[misc] - if not OBSERVABILITY_SETTINGS.ENABLED: + if settings is None or not settings.ENABLED: return super_get_response(messages=messages, stream=stream, options=options, **kwargs) # type: ignore[no-any-return] opts: dict[str, Any] = options or {} # type: ignore[assignment] @@ -1196,7 +1167,7 @@ def get_response( span_name = attributes.get(SpanAttributes.LLM_REQUEST_MODEL, "unknown") span = get_tracer().start_span(f"{operation} {span_name}") span.set_attributes(attributes) - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + if settings.SENSITIVE_DATA_ENABLED and messages: _capture_messages( span=span, provider_name=provider_name, @@ -1231,11 +1202,7 @@ async def _finalize_stream() -> None: operation_duration_histogram=self.duration_histogram, duration=duration, ) - if ( - OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED - and isinstance(response, ChatResponse) - and response.messages - ): + if settings.SENSITIVE_DATA_ENABLED and isinstance(response, ChatResponse) and response.messages: _capture_messages( span=span, provider_name=provider_name, @@ -1256,7 +1223,7 @@ async def _finalize_stream() -> None: async def _get_response() -> ChatResponse: with _get_span(attributes=attributes, span_name_attribute=SpanAttributes.LLM_REQUEST_MODEL) as span: - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + if settings.SENSITIVE_DATA_ENABLED and messages: _capture_messages( span=span, provider_name=provider_name, @@ -1278,7 +1245,7 @@ async def _get_response() -> ChatResponse: operation_duration_histogram=self.duration_histogram, duration=duration, ) - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + if settings.SENSITIVE_DATA_ENABLED and response.messages: _capture_messages( span=span, provider_name=provider_name, @@ -1338,12 +1305,12 @@ def run( **kwargs: Any, ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: """Trace agent runs with OpenTelemetry spans and metrics.""" - global OBSERVABILITY_SETTINGS + settings = OBSERVABILITY_SETTINGS super_run = super().run # type: ignore[misc] provider_name = str(self.otel_provider_name) capture_usage = bool(getattr(self, "_otel_capture_usage", True)) - if not OBSERVABILITY_SETTINGS.ENABLED: + if settings is None or not settings.ENABLED: return super_run( # type: ignore[no-any-return] messages=messages, stream=stream, @@ -1389,7 +1356,7 @@ def run( span_name = attributes.get(OtelAttr.AGENT_NAME, "unknown") span = get_tracer().start_span(f"{operation} {span_name}") span.set_attributes(attributes) - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + if settings.SENSITIVE_DATA_ENABLED and messages: _capture_messages( span=span, provider_name=provider_name, @@ -1422,11 +1389,7 @@ async def _finalize_stream() -> None: capture_usage=capture_usage, ) _capture_response(span=span, attributes=response_attributes, duration=duration) - if ( - OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED - and isinstance(response, AgentResponse) - and response.messages - ): + if settings.SENSITIVE_DATA_ENABLED and isinstance(response, AgentResponse) and response.messages: _capture_messages( span=span, provider_name=provider_name, @@ -1446,7 +1409,7 @@ async def _finalize_stream() -> None: async def _run() -> AgentResponse: with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span: - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + if settings.SENSITIVE_DATA_ENABLED and messages: _capture_messages( span=span, provider_name=provider_name, @@ -1468,7 +1431,7 @@ async def _run() -> AgentResponse: if response: response_attributes = _get_response_attributes(attributes, response, capture_usage=capture_usage) _capture_response(span=span, attributes=response_attributes, duration=duration) - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + if settings.SENSITIVE_DATA_ENABLED and response.messages: _capture_messages( span=span, provider_name=provider_name, @@ -1806,8 +1769,7 @@ def __repr__(self) -> str: def workflow_tracer() -> Tracer: """Get a workflow tracer or a no-op tracer if not enabled.""" - global OBSERVABILITY_SETTINGS - return get_tracer() if OBSERVABILITY_SETTINGS.ENABLED else trace.NoOpTracer() + return get_tracer() if OBSERVABILITY_SETTINGS is not None and OBSERVABILITY_SETTINGS.ENABLED else trace.NoOpTracer() def create_workflow_span( diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index b789d9f532..131341de5e 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -1013,7 +1013,8 @@ def test_enable_instrumentation_function(monkeypatch): observability = importlib.import_module("agent_framework.observability") importlib.reload(observability) - assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is False + # After reload, OBSERVABILITY_SETTINGS is None (deferred creation) + assert observability.OBSERVABILITY_SETTINGS is None observability.enable_instrumentation() assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True @@ -2623,16 +2624,15 @@ def test_configure_otel_providers_reads_sensitive_data_from_env(monkeypatch): ]: monkeypatch.delenv(key, raising=False) - # 2. Reload module — this re-runs ``OBSERVABILITY_SETTINGS = ObservabilitySettings()`` - # with neither env var set, so both cached values are False. + # 2. Reload module — OBSERVABILITY_SETTINGS is now None (deferred creation). importlib.reload(observability) - assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + assert observability.OBSERVABILITY_SETTINGS is None # 3. Simulate load_dotenv() populating the env (happens after import in real samples) monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true") # 4. Call configure_otel_providers() without explicit enable_sensitive_data param. - # Before the fix this left enable_sensitive_data as False. + # Settings are created fresh at call time, reading current os.environ. # Mock _configure to avoid mutating global OTel providers. monkeypatch.setattr(observability.ObservabilitySettings, "_configure", lambda self, **kwargs: None) observability.configure_otel_providers() @@ -2681,7 +2681,7 @@ def test_enable_instrumentation_reads_sensitive_data_from_env(monkeypatch): monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) importlib.reload(observability) - assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False + assert observability.OBSERVABILITY_SETTINGS is None # Simulate load_dotenv() after import monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true") @@ -2727,7 +2727,7 @@ def test_configure_otel_providers_reads_vs_code_port_from_env(monkeypatch): monkeypatch.delenv(key, raising=False) importlib.reload(observability) - assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port is None + assert observability.OBSERVABILITY_SETTINGS is None monkeypatch.setenv("VS_CODE_EXTENSION_PORT", "4317") # Mock _configure to avoid requiring OTLP exporter packages @@ -2735,3 +2735,16 @@ def test_configure_otel_providers_reads_vs_code_port_from_env(monkeypatch): observability.configure_otel_providers() assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317 + + +def test_observability_settings_none_at_import(monkeypatch): + """OBSERVABILITY_SETTINGS should be None at import time (deferred creation).""" + import importlib + + import agent_framework.observability as observability + + monkeypatch.delenv("ENABLE_INSTRUMENTATION", raising=False) + monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False) + + importlib.reload(observability) + assert observability.OBSERVABILITY_SETTINGS is None diff --git a/python/packages/devui/agent_framework_devui/_executor.py b/python/packages/devui/agent_framework_devui/_executor.py index e019917630..52baf37dd3 100644 --- a/python/packages/devui/agent_framework_devui/_executor.py +++ b/python/packages/devui/agent_framework_devui/_executor.py @@ -86,7 +86,7 @@ def _setup_agent_framework_instrumentation(self) -> None: from agent_framework.observability import OBSERVABILITY_SETTINGS, configure_otel_providers # Configure if instrumentation is enabled (via enable_instrumentation() or env var) - if OBSERVABILITY_SETTINGS.ENABLED: + if OBSERVABILITY_SETTINGS is not None and OBSERVABILITY_SETTINGS.ENABLED: # Only configure providers if not already executed if not OBSERVABILITY_SETTINGS._executed_setup: # Call configure_otel_providers to set up exporters.