Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions python/packages/core/agent_framework/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
78 changes: 33 additions & 45 deletions python/packages/core/agent_framework/observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With OBSERVABILITY_SETTINGS now defaulting to None, setting ENABLE_INSTRUMENTATION=true in the process environment no longer automatically enables instrumentation until some code explicitly calls enable_instrumentation()/configure_otel_providers(). This appears to regress the documented “zero-code via env vars” behavior (e.g., python/samples/02-agents/observability/README.md:156 says ENABLE_INSTRUMENTATION=true should cause the framework to emit observability data). Consider lazily initializing OBSERVABILITY_SETTINGS on first telemetry check (or at import time when env vars are already present) so env-only enablement continues to work while still supporting load_dotenv-after-import timing.

Suggested change
OBSERVABILITY_SETTINGS: ObservabilitySettings | None = None
if "ENABLE_INSTRUMENTATION" in os.environ:
OBSERVABILITY_SETTINGS: ObservabilitySettings | None = load_settings()
else:
OBSERVABILITY_SETTINGS: ObservabilitySettings | None = None

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every sample in the repo explicitly calls configure_otel_providers() or enable_instrumentation() — no sample relies on env-var-only enablement. The README's "zero-code" pattern (Pattern 5) refers to the opentelemetry-instrument CLI, which doesn't depend on OBSERVABILITY_SETTINGS. Deferring creation to call time is intentional so that env vars set via load_dotenv() after import are correctly picked up.



def enable_instrumentation(
Expand All @@ -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
Expand Down Expand Up @@ -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]
Comment on lines +1031 to 1048
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configure_otel_providers() now always replaces the global OBSERVABILITY_SETTINGS with a new ObservabilitySettings instance. This breaks the function’s documented “call once”/idempotent behavior: calling configure_otel_providers() a second time will reset _executed_setup back to False and can reconfigure providers/handlers again, which can lead to duplicate exporters/handlers and unexpected telemetry. Consider reusing the existing settings when setup has already been executed (or otherwise preserving the original _executed_setup state) so repeated calls remain a no-op after the first successful configuration.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring states: "DO NOT call this method multiple times, as it may lead to unexpected behavior." Single-call-at-startup is the expected usage pattern. Not a regression.

additional_exporters=exporters,
Expand Down Expand Up @@ -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]
Comment on lines +1132 to 1136
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ChatTelemetryLayer.get_response() currently short-circuits when OBSERVABILITY_SETTINGS is None, which means env-var-only enablement (ENABLE_INSTRUMENTATION=true) won’t activate telemetry unless some other code has already created the settings object. If OBSERVABILITY_SETTINGS is meant to be “deferred”, consider ensuring it is initialized here (or via a shared helper) before checking settings.ENABLED so the first instrumented call can pick up env vars populated by load_dotenv().

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same rationale as above — telemetry activation requires an explicit configure_otel_providers() or enable_instrumentation() call, which all samples do. None means no setup was called, so telemetry should be disabled.


opts: dict[str, Any] = options or {} # type: ignore[assignment]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading