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 8f581a605d..f2fbc5762c 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -880,8 +880,7 @@ def get_meter( return metrics.get_meter(name=name, version=version, schema_url=schema_url) -global OBSERVABILITY_SETTINGS -OBSERVABILITY_SETTINGS: ObservabilitySettings = ObservabilitySettings() +OBSERVABILITY_SETTINGS: ObservabilitySettings | None = None def enable_instrumentation( @@ -900,8 +899,11 @@ 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 + 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 @@ -1026,27 +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 - OBSERVABILITY_SETTINGS.enable_instrumentation = True - if enable_sensitive_data is not None: - OBSERVABILITY_SETTINGS.enable_sensitive_data = enable_sensitive_data - if vs_code_extension_port is not None: - OBSERVABILITY_SETTINGS.vs_code_extension_port = vs_code_extension_port + # 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, @@ -1132,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] @@ -1170,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, @@ -1205,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, @@ -1230,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, @@ -1252,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, @@ -1312,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, @@ -1363,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, @@ -1396,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, @@ -1420,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, @@ -1442,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, @@ -1780,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 fccaf2f9f1..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 @@ -2597,3 +2598,153 @@ 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 — OBSERVABILITY_SETTINGS is now None (deferred creation). + importlib.reload(observability) + 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. + # 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() + + 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. + # 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) + + 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 is None + + # 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 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 + + +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.