diff --git a/python/.env.example b/python/.env.example index 291f0637a2..df3befd4d9 100644 --- a/python/.env.example +++ b/python/.env.example @@ -23,5 +23,4 @@ ANTHROPIC_MODEL="" ENABLE_OTEL=true ENABLE_SENSITIVE_DATA=true OTLP_ENDPOINT="http://localhost:4317/" -# APPLICATIONINSIGHTS_LIVE_METRICS=false # APPLICATIONINSIGHTS_CONNECTION_STRING="..." diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py index abf28dbc8d..758263cefc 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py @@ -165,7 +165,6 @@ class AzureAIAgentClient(BaseChatClient): model_deployment_name: The model deployment name to use for agent creation. Can also be set via 'AZURE_AI_MODEL_DEPLOYMENT_NAME' environment variable. async_credential: Azure async credential to use for authentication. - setup_tracing: Whether to setup tracing for the project_client. Defaults to True. env_file_path: Path to environment file for loading settings. env_file_encoding: Encoding of the environment file. **kwargs: Additional keyword arguments passed to the parent class. @@ -216,7 +215,7 @@ class AzureAIAgentClient(BaseChatClient): ) self._should_close_client = should_close_client - async def setup_observability(self, enable_live_metrics: bool = False) -> None: + async def setup_observability(self) -> None: """Use this method to setup tracing in your Azure AI Project. This will take the connection string from the project project_client. @@ -227,7 +226,6 @@ class AzureAIAgentClient(BaseChatClient): setup_observability( applicationinsights_connection_string=await self.project_client.telemetry.get_application_insights_connection_string(), # noqa: E501 - enable_live_metrics=enable_live_metrics, ) async def __aenter__(self) -> "Self": diff --git a/python/packages/devui/agent_framework_devui/_discovery.py b/python/packages/devui/agent_framework_devui/_discovery.py index d75d77a6c4..e989048ae2 100644 --- a/python/packages/devui/agent_framework_devui/_discovery.py +++ b/python/packages/devui/agent_framework_devui/_discovery.py @@ -40,7 +40,7 @@ class EntityDiscovery: logger.info("No Agent Framework entities directory configured") return [] - entities_dir = Path(self.entities_dir).resolve() + entities_dir = Path(self.entities_dir).resolve() # noqa: ASYNC240 await self._scan_entities_directory(entities_dir) logger.info(f"Discovered {len(self._entities)} Agent Framework entities") @@ -152,7 +152,7 @@ class EntityDiscovery: Args: entities_dir: Directory to scan for entities """ - if not entities_dir.exists(): + if not entities_dir.exists(): # noqa: ASYNC240 logger.warning(f"Entities directory not found: {entities_dir}") return @@ -164,7 +164,7 @@ class EntityDiscovery: sys.path.insert(0, entities_dir_str) # Scan for directories and Python files - for item in entities_dir.iterdir(): + for item in entities_dir.iterdir(): # noqa: ASYNC240 if item.name.startswith(".") or item.name == "__pycache__": continue diff --git a/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py b/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py index 5275b4b302..4fe15a8216 100644 --- a/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py +++ b/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py @@ -33,7 +33,6 @@ class GAIATelemetryConfig: enable_tracing: bool = False, otlp_endpoint: str | None = None, applicationinsights_connection_string: str | None = None, - enable_live_metrics: bool = False, trace_to_file: bool = False, file_path: str | None = None, ): @@ -44,14 +43,12 @@ class GAIATelemetryConfig: enable_tracing: Whether to enable OpenTelemetry tracing otlp_endpoint: OTLP endpoint for trace export applicationinsights_connection_string: Azure Monitor connection string - enable_live_metrics: Enable Azure Monitor live metrics trace_to_file: Whether to export traces to local file file_path: Path for local file export (defaults to gaia_traces.json) """ self.enable_tracing = enable_tracing self.otlp_endpoint = otlp_endpoint self.applicationinsights_connection_string = applicationinsights_connection_string - self.enable_live_metrics = enable_live_metrics self.trace_to_file = trace_to_file self.file_path = file_path or "gaia_traces.json" @@ -66,7 +63,6 @@ class GAIATelemetryConfig: enable_sensitive_data=True, # Enable for detailed task traces otlp_endpoint=self.otlp_endpoint, applicationinsights_connection_string=self.applicationinsights_connection_string, - enable_live_metrics=self.enable_live_metrics, ) # Set up local file export if requested diff --git a/python/packages/lab/gaia/samples/gaia_sample.py b/python/packages/lab/gaia/samples/gaia_sample.py index f6d62a4531..8639bc7ac2 100644 --- a/python/packages/lab/gaia/samples/gaia_sample.py +++ b/python/packages/lab/gaia/samples/gaia_sample.py @@ -30,7 +30,6 @@ async def main() -> None: # Optional: Configure external endpoints # otlp_endpoint="http://localhost:4317", # For Aspire Dashboard or other OTLP endpoints # applicationinsights_connection_string="your_connection_string", # For Azure Monitor - # enable_live_metrics=True, # Enable Azure Monitor live metrics # Configure local file tracing trace_to_file=True, # Export traces to local file file_path="gaia_benchmark_traces.jsonl", # Custom file path for traces diff --git a/python/packages/main/agent_framework/_tools.py b/python/packages/main/agent_framework/_tools.py index d6b03a52b2..0e1f08295f 100644 --- a/python/packages/main/agent_framework/_tools.py +++ b/python/packages/main/agent_framework/_tools.py @@ -404,18 +404,17 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]): Args: arguments: A Pydantic model instance containing the arguments for the function. - otel_settings: Optional model diagnostics settings to override the default settings. kwargs: keyword arguments to pass to the function, will not be used if `arguments` is provided. """ - global OTEL_SETTINGS - from .observability import OTEL_SETTINGS + global OBSERVABILITY_SETTINGS + from .observability import OBSERVABILITY_SETTINGS tool_call_id = kwargs.pop("tool_call_id", None) if arguments is not None: if not isinstance(arguments, self.input_model): raise TypeError(f"Expected {self.input_model.__name__}, got {type(arguments).__name__}") kwargs = arguments.model_dump(exclude_none=True) - if not OTEL_SETTINGS.ENABLED: # type: ignore[name-defined] + if not OBSERVABILITY_SETTINGS.ENABLED: # type: ignore[name-defined] logger.info(f"Function name: {self.name}") logger.debug(f"Function arguments: {kwargs}") res = self.__call__(**kwargs) @@ -425,7 +424,7 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]): return result # type: ignore[reportReturnType] attributes = get_function_span_attributes(self, tool_call_id=tool_call_id) - if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] attributes.update({ OtelAttr.TOOL_ARGUMENTS: arguments.model_dump_json() if arguments @@ -436,7 +435,7 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]): with get_function_span(attributes=attributes) as span: attributes[OtelAttr.MEASUREMENT_FUNCTION_TAG_NAME] = self.name logger.info(f"Function name: {self.name}") - if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] logger.debug(f"Function arguments: {kwargs}") start_time_stamp = perf_counter() end_time_stamp: float | None = None @@ -452,7 +451,7 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]): raise else: logger.info(f"Function {self.name} succeeded.") - if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] try: json_result = json.dumps(result) except (TypeError, OverflowError): diff --git a/python/packages/main/agent_framework/_workflow/_workflow_context.py b/python/packages/main/agent_framework/_workflow/_workflow_context.py index 9bc5864934..aafa4f2d4e 100644 --- a/python/packages/main/agent_framework/_workflow/_workflow_context.py +++ b/python/packages/main/agent_framework/_workflow/_workflow_context.py @@ -356,8 +356,8 @@ class WorkflowContext(Generic[T_Out, T_W_Out]): target_id: The ID of the target executor to send the message to. If None, the message will be sent to all target executors. """ - global OTEL_SETTINGS - from ..observability import OTEL_SETTINGS + global OBSERVABILITY_SETTINGS + from ..observability import OBSERVABILITY_SETTINGS # Create publishing span (inherits current trace context automatically) attributes: dict[str, str] = {OtelAttr.MESSAGE_TYPE: type(message).__name__} @@ -368,7 +368,7 @@ class WorkflowContext(Generic[T_Out, T_W_Out]): msg = Message(data=message, source_id=self._executor_id, target_id=target_id) # Inject current trace context if tracing enabled - if OTEL_SETTINGS.ENABLED and span and span.is_recording(): # type: ignore[name-defined] + if 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/main/agent_framework/observability.py b/python/packages/main/agent_framework/observability.py index 484357ba6b..26081317ca 100644 --- a/python/packages/main/agent_framework/observability.py +++ b/python/packages/main/agent_framework/observability.py @@ -20,13 +20,9 @@ from .exceptions import AgentInitializationError, ChatClientInitializationError if TYPE_CHECKING: # pragma: no cover from azure.core.credentials import TokenCredential - from opentelemetry.sdk._events import EventLoggerProvider - from opentelemetry.sdk._logs import LoggerProvider from opentelemetry.sdk._logs._internal.export import LogExporter - from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import MetricExporter from opentelemetry.sdk.resources import Resource - from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SpanExporter from opentelemetry.trace import Tracer from opentelemetry.util._decorator import _AgnosticContextManager # type: ignore[reportPrivateUsage] @@ -328,11 +324,9 @@ def _create_resource() -> "Resource": return Resource.create({service_attributes.SERVICE_NAME: service_name}) -class OtelSettings(AFBaseSettings): - """Settings for Open Telemetry. +class ObservabilitySettings(AFBaseSettings): + """Settings for Agent Framework Observability. - The settings are first loaded from environment variables with - the prefix 'AGENT_FRAMEWORK_GENAI_'. If the environment variables are not found, the settings can be loaded from a .env file with the encoding 'utf-8'. If the settings are not found in the .env file, the settings @@ -349,10 +343,11 @@ class OtelSettings(AFBaseSettings): (Env var ENABLE_SENSITIVE_DATA) applicationinsights_connection_string: The Azure Monitor connection string. Default is None. (Env var APPLICATIONINSIGHTS_CONNECTION_STRING) - applicationinsights_live_metrics: Enable Azure Monitor live metrics. Default is False. - (Env var APPLICATIONINSIGHTS_LIVE_METRICS) otlp_endpoint: The OpenTelemetry Protocol (OTLP) endpoint. Default is None. (Env var OTLP_ENDPOINT) + vs_code_extension_port: The port the AI Toolkit or AzureAI Foundry VS Code extensions are listening on. + Default is None. + (Env var VS_CODE_EXTENSION_PORT) """ env_prefix: ClassVar[str] = "" @@ -360,14 +355,10 @@ class OtelSettings(AFBaseSettings): enable_otel: bool = False enable_sensitive_data: bool = False applicationinsights_connection_string: str | list[str] | None = None - applicationinsights_live_metrics: bool = False otlp_endpoint: str | list[str] | None = None + vs_code_extension_port: int | None = None _resource: "Resource" = PrivateAttr(default_factory=_create_resource) _executed_setup: bool = PrivateAttr(default=False) - _tracer_provider: "TracerProvider | None" = PrivateAttr(default=None) - _meter_provider: "MeterProvider | None" = PrivateAttr(default=None) - _logger_provider: "LoggerProvider | None" = PrivateAttr(default=None) - _event_logger_provider: "EventLoggerProvider | None" = PrivateAttr(default=None) @property def ENABLED(self) -> bool: @@ -400,24 +391,24 @@ class OtelSettings(AFBaseSettings): """Set the resource.""" self._resource = value - def setup_observability( + def _configure( self, credential: "TokenCredential | None" = None, additional_exporters: list["LogExporter | SpanExporter | MetricExporter"] | None = None, - force_setup: bool = False, ) -> None: - """Setup telemetry based on the settings. + """Configure application-wide observability based on the settings. + + This method is a helper method to create the log, trace and metric providers. + This method is intended to be called once during the application startup. Calling it multiple times + will have no effect. Args: credential: The credential to use for Azure Monitor Entra ID authentication. Default is None. additional_exporters: A list of additional exporters to add to the configuration. Default is None. - force_setup: Force the setup to be executed even if it has already been executed. Default is False. """ - if (not self.ENABLED) or (self._executed_setup and not force_setup): + if not self.ENABLED or self._executed_setup: return - global_logger = logging.getLogger() - global_logger.setLevel(logging.NOTSET) exporters: list["LogExporter | SpanExporter | MetricExporter"] = additional_exporters or [] if self.otlp_endpoint: exporters.extend( @@ -438,26 +429,6 @@ class OtelSettings(AFBaseSettings): ) self._configure_providers(exporters) self._executed_setup = True - if self.applicationinsights_connection_string and self.applicationinsights_live_metrics: - from azure.monitor.opentelemetry import configure_azure_monitor - - conn_strings = ( - self.applicationinsights_connection_string - if isinstance(self.applicationinsights_connection_string, list) - else [self.applicationinsights_connection_string] - ) - for con_str in conn_strings: - # only configure using this for live_metrics, ignore the rest. - configure_azure_monitor( - connection_string=con_str, - credential=credential, - logger_name="agent_framework", - resource=self.resource, - enable_live_metrics=self.applicationinsights_live_metrics, - disable_logging=True, - disable_metric=True, - disable_tracing=True, - ) def check_endpoint_already_configured(self, otlp_endpoint: str) -> bool: """Check if the endpoint is already configured. @@ -467,9 +438,7 @@ class OtelSettings(AFBaseSettings): """ if not self.otlp_endpoint: return False - return otlp_endpoint not in ( - self.otlp_endpoint if isinstance(self.otlp_endpoint, list) else [self.otlp_endpoint] - ) + return otlp_endpoint in (self.otlp_endpoint if isinstance(self.otlp_endpoint, list) else [self.otlp_endpoint]) def check_connection_string_already_configured(self, connection_string: str) -> bool: """Check if the connection string is already configured. @@ -479,7 +448,7 @@ class OtelSettings(AFBaseSettings): """ if not self.applicationinsights_connection_string: return False - return connection_string not in ( + return connection_string in ( self.applicationinsights_connection_string if isinstance(self.applicationinsights_connection_string, list) else [self.applicationinsights_connection_string] @@ -488,7 +457,6 @@ class OtelSettings(AFBaseSettings): def _configure_providers(self, exporters: list["LogExporter | MetricExporter | SpanExporter"]) -> None: """Configure tracing, logging, events and metrics with the provided exporters.""" from opentelemetry._logs import set_logger_provider - from opentelemetry.sdk._events import EventLoggerProvider from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler from opentelemetry.sdk._logs._internal.export import LogExporter from opentelemetry.sdk._logs.export import BatchLogRecordProcessor @@ -498,52 +466,49 @@ class OtelSettings(AFBaseSettings): from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExporter - # Use SimpleSpanProcessor for in-memory exporter (tests) so spans are - # exported synchronously and immediately available via - # InMemorySpanExporter.get_finished_spans(). For all other exporters - # keep using the BatchSpanProcessor behavior. - # Tracing - if not self._executed_setup: - new_tracer_provider = TracerProvider(resource=self.resource) - # setting global tracer provider, other libaries can use this, - # but if another global tracer provider is already set this will not override it. - trace.set_tracer_provider(new_tracer_provider) - tracer_provider = trace.get_tracer_provider() + tracer_provider = TracerProvider(resource=self.resource) + trace.set_tracer_provider(tracer_provider) + should_add_console_exporter = True for exporter in exporters: - if not isinstance(exporter, SpanExporter): - continue - if (add_span_processor := getattr(tracer_provider, "add_span_processor", None)) and callable( - add_span_processor - ): - add_span_processor(BatchSpanProcessor(exporter)) + if isinstance(exporter, SpanExporter): + tracer_provider.add_span_processor(BatchSpanProcessor(exporter)) + should_add_console_exporter = False + if should_add_console_exporter: + from opentelemetry.sdk.trace.export import ConsoleSpanExporter + + tracer_provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter())) # Logging - if not self._logger_provider: - self._logger_provider = LoggerProvider(resource=self.resource) + logger_provider = LoggerProvider(resource=self.resource) + should_add_console_exporter = True + for exporter in exporters: + if isinstance(exporter, LogExporter): + logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter)) + should_add_console_exporter = False + if should_add_console_exporter: + from opentelemetry.sdk._logs._internal.export import ConsoleLogExporter - [ - self._logger_provider.add_log_record_processor(BatchLogRecordProcessor(exporter)) - for exporter in exporters - if isinstance(exporter, LogExporter) - ] - logger = get_logger() - if not any(isinstance(handler, LoggingHandler) for handler in logger.handlers): - handler = LoggingHandler(logger_provider=self._logger_provider) - logger.addHandler(handler) - logger.setLevel(logging.NOTSET) - set_logger_provider(self._logger_provider) - # Events - if not self._event_logger_provider: - self._event_logger_provider = EventLoggerProvider(self._logger_provider) + logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogExporter())) + + # Attach a handler with the provider to the root logger + logger = logging.getLogger() + handler = LoggingHandler(logger_provider=logger_provider) + logger.addHandler(handler) + set_logger_provider(logger_provider) # metrics + metric_readers = [ + PeriodicExportingMetricReader(exporter, export_interval_millis=5000) + for exporter in exporters + if isinstance(exporter, MetricExporter) + ] + if not metric_readers: + from opentelemetry.sdk.metrics.export import ConsoleMetricExporter + + metric_readers = [PeriodicExportingMetricReader(ConsoleMetricExporter(), export_interval_millis=5000)] meter_provider = MeterProvider( - metric_readers=[ - PeriodicExportingMetricReader(exporter, export_interval_millis=5000) - for exporter in exporters - if isinstance(exporter, MetricExporter) - ], + metric_readers=metric_readers, resource=self.resource, views=[ # Dropping all instrument names except for those starting with "agent_framework" @@ -555,10 +520,6 @@ class OtelSettings(AFBaseSettings): metrics.set_meter_provider(meter_provider) -global OTEL_SETTINGS -OTEL_SETTINGS: OtelSettings = OtelSettings() - - def get_tracer( instrumenting_module_name: str = "agent_framework", instrumenting_library_version: str = version_info, @@ -606,74 +567,152 @@ def get_meter( return metrics.get_meter(name=name, version=version, schema_url=schema_url, attributes=attributes) +global OBSERVABILITY_SETTINGS +OBSERVABILITY_SETTINGS: ObservabilitySettings = ObservabilitySettings() + + def setup_observability( enable_sensitive_data: bool | None = None, otlp_endpoint: str | list[str] | None = None, applicationinsights_connection_string: str | list[str] | None = None, credential: "TokenCredential | None" = None, - enable_live_metrics: bool | None = None, exporters: list["LogExporter | SpanExporter | MetricExporter"] | None = None, + vs_code_extension_port: int | None = None, ) -> None: - """Setup telemetry with optionally provided settings, it is implied that you want to enable telemetry. + """Convenient method to setup observability for the application. - All of these values can be set through environment variables or you can pass them here, - in the case where both are present, the provided value takes precedence. + This method will create the exporters and the providers for the application, + based on the provided values and the environment variables. - If you have both connection_string and otlp_endpoint, the connection_string will be used. + Call this method once during application startup, before any telemetry is captured. + DO NOT call this method multiple times, as it may lead to unexpected behavior. + + If you have configured the providers manually, calling this method will not have any effect: + + ```python + # Some where in your application startup code + trace.set_tracer_provider(TracerProvider(...)) + + # After the above call, calling setup_observability will not have any effect + setup_observability() + ``` + + The reverse is also true: + + ```python + # Some where in your application startup code + setup_observability() + + # After the above call, calling trace.set_tracer_provider will not have any effect + trace.set_tracer_provider(TracerProvider(...)) + ``` + + The OTel endpoint and the Application Insights connection string can be set through + environment variables or you can pass additional ones here. In the case where both + are present, non-duplicate values will be added: + + ## With environment variables + + This method will read the settings from the environment: + + ```python + setup_observability() + ``` + + ## Without environment variables and use parameters + + It is also possible to pass the settings directly: + + ```python + setup_observability( + enable_sensitive_data=True, + otlp_endpoint=["http://localhost:7431"], + applicationinsights_connection_string=["..."], + exporters=[...], # your custom exporters + vs_code_extension_port=4317, + ) + ``` + + ## Mixed + + When both environment variables and parameters are used, the following settings will get overridden: + - enable_sensitive_data + - vs_code_extension_port + + The endpoints and connection strings will be combined, excluding duplicates. + + ```env + OTEL_ENDPOINT="http://localhost:7431" + ``` + + ```python + setup_observability( + enable_sensitive_data=True, + otlp_endpoint=["http://localhost:4317"], + ) + ``` + + Exporters will be created for both endpoints. Args: - enable_sensitive_data: Enable OpenTelemetry sensitive events. Default is False. + enable_sensitive_data: Enable OpenTelemetry sensitive events. + If set, this will override the value set through the environment variable. + Default is None. otlp_endpoint: The OpenTelemetry Protocol (OTLP) endpoint. Default is None. Will be used to create a `OTLPLogExporter`, `OTLPMetricExporter` and `OTLPSpanExporter` applicationinsights_connection_string: The Azure Monitor connection string. Default is None. Will be used to create AzureMonitorExporters. credential: The credential to use for Azure Monitor Entra ID authentication. Default is None. - enable_live_metrics: Enable Azure Monitor live metrics. Default is False. - exporters: a list of exporters, for logs, metrics or spans, or any combination, - these will be added directly, and allows you to customize the spans completely - + exporters: A list of exporters, for logs, metrics or spans, or any combination. + These will be added directly, and allows you to customize the spans completely. + vs_code_extension_port: The port the AI Toolkit or AzureAI Foundry VS Code extensions are + listening on. When this is set, additional OTEL exporters will be created with endpoint + `http://localhost:{vs_code_extension_port}` unless this endpoint is already configured. + This will override the value set through the environment variable. + Default is None. """ - global OTEL_SETTINGS - # Update the otel settings with the provided values - OTEL_SETTINGS.enable_otel = True + global OBSERVABILITY_SETTINGS + # Update the observability settings with the provided values + OBSERVABILITY_SETTINGS.enable_otel = True if enable_sensitive_data is not None: - OTEL_SETTINGS.enable_sensitive_data = enable_sensitive_data - if enable_live_metrics is not None: - OTEL_SETTINGS.applicationinsights_live_metrics = enable_live_metrics - # Run the initial setup, which will create the providers, and add env setting exporters - new_exporters: list["LogExporter | SpanExporter | MetricExporter"] = [] - if OTEL_SETTINGS.ENABLED and (otlp_endpoint or applicationinsights_connection_string or exporters): - # create the exporters, after checking if they are already configured through the env. - new_exporters = exporters or [] - if otlp_endpoint: - if isinstance(otlp_endpoint, str): - otlp_endpoint = [otlp_endpoint] - new_exporters.extend( - _get_otlp_exporters( - endpoints=[ - endpoint - for endpoint in otlp_endpoint - if not OTEL_SETTINGS.check_endpoint_already_configured(endpoint) - ] - ) + 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 + + # Create exporters, after checking if they are already configured through the env. + new_exporters: list["LogExporter | SpanExporter | MetricExporter"] = exporters or [] + if otlp_endpoint: + if isinstance(otlp_endpoint, str): + otlp_endpoint = [otlp_endpoint] + new_exporters.extend( + _get_otlp_exporters( + endpoints=[ + endpoint + for endpoint in otlp_endpoint + if not OBSERVABILITY_SETTINGS.check_endpoint_already_configured(endpoint) + ] ) - if applicationinsights_connection_string: - if isinstance(applicationinsights_connection_string, str): - applicationinsights_connection_string = [applicationinsights_connection_string] - new_exporters.extend( - _get_azure_monitor_exporters( - connection_strings=[ - conn_str - for conn_str in applicationinsights_connection_string - if not OTEL_SETTINGS.check_connection_string_already_configured(conn_str) - ], - credential=credential, - ) + ) + if applicationinsights_connection_string: + if isinstance(applicationinsights_connection_string, str): + applicationinsights_connection_string = [applicationinsights_connection_string] + new_exporters.extend( + _get_azure_monitor_exporters( + connection_strings=[ + conn_str + for conn_str in applicationinsights_connection_string + if not OBSERVABILITY_SETTINGS.check_connection_string_already_configured(conn_str) + ], + credential=credential, ) - OTEL_SETTINGS.setup_observability( - credential=credential, additional_exporters=new_exporters, force_setup=bool(new_exporters) - ) + ) + if OBSERVABILITY_SETTINGS.vs_code_extension_port: + endpoint = f"http://localhost:{OBSERVABILITY_SETTINGS.vs_code_extension_port}" + if OBSERVABILITY_SETTINGS.check_endpoint_already_configured(endpoint): + new_exporters.extend(_get_otlp_exporters(endpoints=[endpoint])) + + OBSERVABILITY_SETTINGS._configure(credential=credential, additional_exporters=new_exporters) # pyright: ignore[reportPrivateUsage] # region Chat Client Telemetry @@ -721,8 +760,8 @@ def _trace_get_response( messages: "str | ChatMessage | list[str] | list[ChatMessage]", **kwargs: Any, ) -> "ChatResponse": - global OTEL_SETTINGS - if not OTEL_SETTINGS.ENABLED: + global OBSERVABILITY_SETTINGS + if not OBSERVABILITY_SETTINGS.ENABLED: # If model diagnostics are not enabled, just return the completion return await func( self, @@ -747,7 +786,7 @@ def _trace_get_response( **kwargs, ) with _get_span(attributes=attributes, span_name_attribute=SpanAttributes.LLM_REQUEST_MODEL) as span: - if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: _capture_messages(span=span, provider_name=provider_name, messages=messages) start_time_stamp = perf_counter() end_time_stamp: float | None = None @@ -767,7 +806,7 @@ def _trace_get_response( token_usage_histogram=self.additional_properties["token_usage_histogram"], operation_duration_histogram=self.additional_properties["operation_duration_histogram"], ) - if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: _capture_messages( span=span, provider_name=provider_name, @@ -803,8 +842,8 @@ def _trace_get_streaming_response( async def trace_get_streaming_response( self: "ChatClientProtocol", messages: "str | ChatMessage | list[str] | list[ChatMessage]", **kwargs: Any ) -> AsyncIterable["ChatResponseUpdate"]: - global OTEL_SETTINGS - if not OTEL_SETTINGS.ENABLED: + global OBSERVABILITY_SETTINGS + if not OBSERVABILITY_SETTINGS.ENABLED: # If model diagnostics are not enabled, just return the completion async for update in func(self, messages=messages, **kwargs): yield update @@ -829,7 +868,7 @@ def _trace_get_streaming_response( ) all_updates: list["ChatResponseUpdate"] = [] with _get_span(attributes=attributes, span_name_attribute=SpanAttributes.LLM_REQUEST_MODEL) as span: - if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: _capture_messages( span=span, provider_name=provider_name, @@ -859,7 +898,7 @@ def _trace_get_streaming_response( operation_duration_histogram=self.additional_properties["operation_duration_histogram"], ) - if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: _capture_messages( span=span, provider_name=provider_name, @@ -937,9 +976,9 @@ def _trace_agent_run( thread: "AgentThread | None" = None, **kwargs: Any, ) -> "AgentRunResponse": - global OTEL_SETTINGS + global OBSERVABILITY_SETTINGS - if not OTEL_SETTINGS.ENABLED: + if not OBSERVABILITY_SETTINGS.ENABLED: # If model diagnostics are not enabled, just return the completion return await run_func(self, messages=messages, thread=thread, **kwargs) @@ -953,7 +992,7 @@ def _trace_agent_run( **kwargs, ) with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span: - if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: _capture_messages( span=span, provider_name=provider_name, @@ -968,7 +1007,7 @@ def _trace_agent_run( else: attributes = _get_response_attributes(attributes, response) _capture_response(span=span, attributes=attributes) - if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: _capture_messages( span=span, provider_name=provider_name, @@ -1000,9 +1039,9 @@ def _trace_agent_run_stream( thread: "AgentThread | None" = None, **kwargs: Any, ) -> AsyncIterable["AgentRunResponseUpdate"]: - global OTEL_SETTINGS + global OBSERVABILITY_SETTINGS - if not OTEL_SETTINGS.ENABLED: + if not OBSERVABILITY_SETTINGS.ENABLED: # If model diagnostics are not enabled, just return the completion async for streaming_agent_response in run_streaming_func(self, messages=messages, thread=thread, **kwargs): yield streaming_agent_response @@ -1022,7 +1061,7 @@ def _trace_agent_run_stream( **kwargs, ) with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span: - if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and messages: + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: _capture_messages( span=span, provider_name=provider_name, @@ -1040,7 +1079,7 @@ def _trace_agent_run_stream( response = AgentRunResponse.from_agent_run_response_updates(all_updates) attributes = _get_response_attributes(attributes, response) _capture_response(span=span, attributes=attributes) - if OTEL_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: _capture_messages( span=span, provider_name=provider_name, @@ -1342,8 +1381,8 @@ class EdgeGroupDeliveryStatus(Enum): def workflow_tracer() -> "Tracer": """Get a workflow tracer or a no-op tracer if not enabled.""" - global OTEL_SETTINGS - return get_tracer() if OTEL_SETTINGS.ENABLED else trace.NoOpTracer() + global OBSERVABILITY_SETTINGS + return get_tracer() if OBSERVABILITY_SETTINGS.ENABLED else trace.NoOpTracer() def create_workflow_span( diff --git a/python/packages/main/tests/conftest.py b/python/packages/main/tests/conftest.py index c4df71e98e..cf4d42f150 100644 --- a/python/packages/main/tests/conftest.py +++ b/python/packages/main/tests/conftest.py @@ -23,14 +23,13 @@ def enable_sensitive_data(request: Any) -> bool: @fixture(autouse=True) def span_exporter(monkeypatch, enable_otel: bool, enable_sensitive_data: bool) -> Generator[SpanExporter]: - """Fixture to remove environment variables for OtelSettings.""" + """Fixture to remove environment variables for ObservabilitySettings.""" env_vars = [ "ENABLE_OTEL", "ENABLE_SENSITIVE_DATA", "OTLP_ENDPOINT", "APPLICATIONINSIGHTS_CONNECTION_STRING", - "APPLICATIONINSIGHTS_LIVE_METRICS", ] for key in env_vars: @@ -47,22 +46,25 @@ def span_exporter(monkeypatch, enable_otel: bool, enable_sensitive_data: bool) - import agent_framework.observability as observability # Reload the module to ensure a clean state for tests, then create a - # fresh OtelSettings instance and patch the module attribute. + # fresh ObservabilitySettings instance and patch the module attribute. importlib.reload(observability) - # recreate otel settings with values from above and no file. - otel = observability.OtelSettings(env_file_path="test.env") - otel.setup_observability() - monkeypatch.setattr(observability, "OTEL_SETTINGS", otel, raising=False) # type: ignore - exporter = InMemorySpanExporter() + # recreate observability settings with values from above and no file. + observability_settings = observability.ObservabilitySettings(env_file_path="test.env") + observability_settings._configure() # pyright: ignore[reportPrivateUsage] + monkeypatch.setattr(observability, "OBSERVABILITY_SETTINGS", observability_settings, raising=False) # type: ignore + with ( - patch("agent_framework.observability.OTEL_SETTINGS", otel), + patch("agent_framework.observability.OBSERVABILITY_SETTINGS", observability_settings), patch("agent_framework.observability.setup_observability"), ): + exporter = InMemorySpanExporter() if enable_otel or enable_sensitive_data: - trace.get_tracer_provider().add_span_processor( - SimpleSpanProcessor(exporter) # type: ignore[func-returns-value] - ) + tracer_provider = trace.get_tracer_provider() + if not hasattr(tracer_provider, "add_span_processor"): + raise RuntimeError("Tracer provider does not support adding span processors.") + + tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) # type: ignore yield exporter # Clean up diff --git a/python/samples/getting_started/observability/.env.example b/python/samples/getting_started/observability/.env.example index 3d7c49d47e..4bbaa172cf 100644 --- a/python/samples/getting_started/observability/.env.example +++ b/python/samples/getting_started/observability/.env.example @@ -1,12 +1,14 @@ -# Connector environment variables -# Azure AI -# see ../../../env.example for details -# OpenAI -# see ../../../env.example for details - -# Otel specific variables APPLICATIONINSIGHTS_CONNECTION_STRING="..." -APPLICATIONINSIGHTS_LIVE_METRICS=true OTLP_ENDPOINT="http://localhost:4317/" -ENABLE_OTEL=true ENABLE_SENSITIVE_DATA=true +# This is not required if you run `setup_observability()` in your code +ENABLE_OTEL=true + +# OpenAI specific variables +OPENAI_API_KEY="..." +OPENAI_RESPONSES_MODEL_ID="gpt-4o-2024-08-06" +OPENAI_CHAT_MODEL_ID="gpt-4o-2024-08-06" + +# Foundry specific variables +AZURE_AI_PROJECT_ENDPOINT="..." +AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" \ No newline at end of file diff --git a/python/samples/getting_started/observability/04-workflow.py b/python/samples/getting_started/observability/04-workflow.py deleted file mode 100644 index 027812aafd..0000000000 --- a/python/samples/getting_started/observability/04-workflow.py +++ /dev/null @@ -1,115 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. -# type: ignore -import asyncio -from typing import Any - -from agent_framework import ( - Executor, - WorkflowBuilder, - WorkflowContext, - WorkflowOutputEvent, - handler, -) -from agent_framework.observability import get_tracer, setup_observability -from opentelemetry.trace import SpanKind -from opentelemetry.trace.span import format_trace_id - -"""Telemetry sample demonstrating OpenTelemetry integration with Agent Framework workflows. - -This sample runs a simple sequential workflow with telemetry collection, -showing telemetry collection for workflow execution, executor processing, -and message publishing between executors. -""" -tracer = get_tracer("agent_framework.workflow") - - -# Executors for sequential workflow -class UpperCaseExecutor(Executor): - """An executor that converts text to uppercase.""" - - @handler - async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None: - """Execute the task by converting the input string to uppercase.""" - print(f"UpperCaseExecutor: Processing '{text}'") - result = text.upper() - print(f"UpperCaseExecutor: Result '{result}'") - - # Send the result to the next executor in the workflow. - await ctx.send_message(result) - - -class ReverseTextExecutor(Executor): - """An executor that reverses text.""" - - @handler - async def reverse_text(self, text: str, ctx: WorkflowContext[Any, str]) -> None: - """Execute the task by reversing the input string.""" - print(f"ReverseTextExecutor: Processing '{text}'") - result = text[::-1] - print(f"ReverseTextExecutor: Result '{result}'") - - # Yield the output. - await ctx.yield_output(result) - - -async def run_sequential_workflow() -> None: - """Run a simple sequential workflow demonstrating telemetry collection. - - This workflow processes a string through two executors in sequence: - 1. UpperCaseExecutor converts the input to uppercase - 2. ReverseTextExecutor reverses the string and completes the workflow - - Telemetry data collected includes: - - Overall workflow execution spans - - Individual executor processing spans - - Message publishing between executors - - Workflow completion events - """ - with tracer.start_as_current_span("Scenario: Sequential Workflow", kind=SpanKind.CLIENT) as current_span: - print("Running scenario: Sequential Workflow") - try: - # Step 1: Create the executors. - upper_case_executor = UpperCaseExecutor(id="upper_case_executor") - reverse_text_executor = ReverseTextExecutor(id="reverse_text_executor") - - # Step 2: Build the workflow with the defined edges. - workflow = ( - WorkflowBuilder() - .add_edge(upper_case_executor, reverse_text_executor) - .set_start_executor(upper_case_executor) - .build() - ) - - # Step 3: Run the workflow with an initial message. - input_text = "hello world" - print(f"Starting workflow with input: '{input_text}'") - - output_event = None - async for event in workflow.run_stream(input_text): - print(f"Event: {event}") - if isinstance(event, WorkflowOutputEvent): - # The WorkflowOutputEvent contains the final result. - output_event = event - - if output_event: - print(f"Workflow completed with result: '{output_event.data}'") - else: - print("Workflow completed without an output event") - - except Exception as e: - current_span.record_exception(e) - print(f"Error running workflow: {e}") - - -async def main(): - """Run the telemetry sample with a simple sequential workflow.""" - setup_observability() - with tracer.start_as_current_span("Sequential Workflow Scenario", kind=SpanKind.CLIENT) as current_span: - print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - - # Run the sequential workflow scenario - await run_sequential_workflow() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/observability/README.md b/python/samples/getting_started/observability/README.md index 5c5c973d92..c9620f8b68 100644 --- a/python/samples/getting_started/observability/README.md +++ b/python/samples/getting_started/observability/README.md @@ -1,80 +1,116 @@ # Agent Framework Python Observability -This sample folder shows how a Python application can be configured to send Agent Framework observability data to the Application Performance Management (APM) vendor(s) of your choice based on the Open Telemetry standard. +This sample folder shows how a Python application can be configured to send Agent Framework observability data to the Application Performance Management (APM) vendor(s) of your choice based on the OpenTelemetry standard. In this sample, we provide options to send telemetry to [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview), [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/overview?tabs=bash) and the console. -> **Quick Start**: For local development without Azure setup, you can use the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone) which runs locally via Docker and provides an excellent telemetry viewing experience for OpenTelemetry data. -Or you can use the built-in tracing module of the [AI Toolkit for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio). +> **Quick Start**: For local development without Azure setup, you can use the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone) which runs locally via Docker and provides an excellent telemetry viewing experience for OpenTelemetry data. Or you can use the built-in tracing module of the [AI Toolkit for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio). -> Note that it is also possible to use other Application Performance Management (APM) vendors. An example is [Prometheus](https://prometheus.io/docs/introduction/overview/). Please refer to this [link](https://opentelemetry.io/docs/languages/python/exporters/) to learn more about exporters. +> Note that it is also possible to use other Application Performance Management (APM) vendors. An example is [Prometheus](https://prometheus.io/docs/introduction/overview/). Please refer to this [page](https://opentelemetry.io/docs/languages/python/exporters/) to learn more about exporters. For more information, please refer to the following resources: 1. [Azure Monitor OpenTelemetry Exporter](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/monitor/azure-monitor-opentelemetry-exporter) 2. [Aspire Dashboard for Python Apps](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone-for-python?tabs=flask%2Cwindows) -2. [AI Toolkit for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio) -3. [Python Logging](https://docs.python.org/3/library/logging.html) -4. [Observability in Python](https://www.cncf.io/blog/2022/04/22/opentelemetry-and-python-a-complete-instrumentation-guide/) +3. [AI Toolkit for VS Code](https://marketplace.visualstudio.com/items?itemName=ms-windows-ai-studio.windows-ai-studio) +4. [Python Logging](https://docs.python.org/3/library/logging.html) +5. [Observability in Python](https://www.cncf.io/blog/2022/04/22/opentelemetry-and-python-a-complete-instrumentation-guide/) ## What to expect -The Agent Framework Python SDK is designed to efficiently generate comprehensive logs, traces, and metrics throughout the flow of function execution and model invocation. This allows you to effectively monitor your AI application's performance and accurately track token consumption. It does so based on the Semantic Conventions for GenAI defined by OpenTelemetry, and the workflows emit their own spans to provide end-to-end visibility. +The Agent Framework Python SDK is designed to efficiently generate comprehensive logs, traces, and metrics throughout the flow of agent/model invocation and tool execution. This allows you to effectively monitor your AI application's performance and accurately track token consumption. It does so based on the Semantic Conventions for GenAI defined by OpenTelemetry, and the workflows emit their own spans to provide end-to-end visibility. ## Configuration ### Required resources -2. OpenAI or [Azure OpenAI](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/create-resource?pivots=web-portal) -2. [Azure AI project](https://ai.azure.com/doc/azure/ai-foundry/what-is-azure-ai-foundry) +1. OpenAI or [Azure OpenAI](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/create-resource?pivots=web-portal) +2. An [Azure AI project](https://ai.azure.com/doc/azure/ai-foundry/what-is-azure-ai-foundry) ### Optional resources +The following resources are needed if you want to send telemetry data to them: + 1. [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/create-workspace-resource) 2. [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone-for-python?tabs=flask%2Cwindows#start-the-aspire-dashboard) ### Dependencies + No additional dependencies are required to enable telemetry. The necessary packages are included as part of the `agent-framework` package. Unless you want to use a different APM vendor, in which case you will need to install the appropriate OpenTelemetry exporter package. ### Environment variables -The following environment variables can be set to configure telemetry, the first two set the basic configuration: + +The following environment variables are used to turn on/off observability of the Agent Framework: - ENABLE_OTEL=true - ENABLE_SENSITIVE_DATA=true -Next we need to know where to send the telemetry, for that you can use either a OTLP endpoint or a connection string for Application Insights: -- OTLP_ENDPOINT="" -or -- APPLICATIONINSIGHTS_CONNECTION_STRING="" -Finally, you can enable live metrics streaming to Application Insights: -- APPLICATIONINSIGHTS_LIVE_METRICS=true +The framework will emit observability data when one of the above environment variables is set to true. + +> **Note**: Sensitive information includes prompts, responses, and more, and should only be enabled in a development or test environment. It is not recommended to enable this in production environments as it may expose sensitive data. + +### Configuring exporters and providers + +Turning on observability is just the first step, you also need to configure where to send the observability data (i.e. Console, Application Insights). By default, no exporters or providers are configured. + +#### Setting up exporters and providers manually + +Please refer to sample [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) for a comprehensive example of how to manually setup exporters and providers for traces, logs, and metrics that will get sent to the console. + +#### Setting up exporters and providers using `setup_observability()` + +To make it easier for developers to get started, the `agent_framework.observability` module provides a `setup_observability()` function that will setup exporters and providers for traces, logs, and metrics based on environment variables. You can call this function at the start of your application to enable telemetry. + +```python +from agent_framework.observability import setup_observability + +setup_observability() +``` + +#### Environment variables for `setup_observability()` + +The `setup_observability()` function will look for the following environment variables to determine how to setup the exporters and providers: + +- OTLP_ENDPOINT="..." +- APPLICATIONINSIGHTS_CONNECTION_STRING="..." + +By providing the above environment variables, the `setup_observability()` function will automatically configure the appropriate exporters and providers for you. If no environment variables are provided, the function will not setup any exporters or providers. + +You can also pass in a list of exporters directly to the `setup_observability()` function if you want to customize the exporters or add additional ones besides the ones configured via environment variables. + +```python +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from agent_framework.observability import setup_observability + +exporter = OTLPSpanExporter(endpoint="another-otlp-endpoint") +setup_observability(exporters=[exporter]) +``` + +> Using this method implicitly enables telemetry, so you do not need to set the `ENABLE_OTEL` environment variable. You can still set `ENABLE_SENSITIVE_DATA` to control whether sensitive data is included in the telemetry, or call the `setup_observability()` function with the `enable_sensitive_data` parameter set to `True`. ## Samples + This folder contains different samples demonstrating how to use telemetry in various scenarios. -### [01 - zero_code](./01-zero_code.py): -A simple example showing how to enable telemetry in a zero-touch scenario. When the above environment variables are set, telemetry will be automatically enabled, however since you do not define any overarching tracer, you will only see the spans for the specific calls to the chat client and tools. +| Sample | Description | +|--------|-------------| +| [setup_observability_with_parameters.py](./setup_observability_with_parameters.py) | A simple example showing how to setup telemetry by passing in parameters to the `setup_observability()` function. | +| [setup_observability_with_env_vars.py](./setup_observability_with_env_vars.py) | A simple example showing how to setup telemetry with the `setup_observability()` function using environment variables. | +| [agent_observability.py](./agent_observability.py) | A simple example showing how to setup telemetry for an agentic application. | +| [azure_ai_agent_observability.py](./azure_ai_agent_observability.py) | A simple example showing how to setup telemetry for an agentic application with an Azure AI project. | +| [azure_ai_chat_client_with_observability.py](./azure_ai_chat_client_with_observability.py) | A simple example showing how to setup telemetry for a chat client with an Azure AI project. | +| [workflow_observability.py](./workflow_observability.py) | A simple example showing how to setup telemetry for a workflow. | +| [advanced_manual_setup_console_output.py](./advanced_manual_setup_console_output.py) | A comprehensive example showing how to manually setup exporters and providers for traces, logs, and metrics that will get sent to the console. | +| [advanced_zero_code.py](./advanced_zero_code.py) | A comprehensive example showing how to setup telemetry using the `opentelemetry-instrument` lib without modifying any code. | -### [02a](./02a-generic_chat_client.py) and [02b](./02b-azure_ai_chat_client.py) Chat Clients: -These two samples show how to first setup the telemetry by manually importing the `setup_observability` function from the `agent_framework.observability` module and calling it. After this is done, the trace that get's created will live in the same context as the chat client calls, allowing you to see the end-to-end flow of your application. For Azure AI, there is a method in the Azure AI project client to get the azure monitor connection string for your project, the `.setup_observability()` method in the `AzureAIAgentClient` class will use this url to configure telemetry and you then do not have to import and call `setup_observability()` manually. -If you or some other process already configure global tracer_providers or metrics_providers, the `setup_observability()` function will not override them, but instead use the existing tracer_provider, if possible. Metrics cannot be setup this way, so if you want to use metrics, you will have to call `setup_observability()` manually, before another process. - -### [03a](./03a-generic_agent.py) and [03b](./03b-azure_ai_agent.py) Agents: -These two samples show how to setup telemetry when using the Agent Framework's agent abstraction layer. They are similar to the chat client samples, but also show how to create an agent and invoke it. The same rules apply for setting up telemetry, you can either call `setup_observability()` manually, or use the `setup_observability()` method in the `AzureAIAgentClient` class. - -### [04 - workflow](./04-workflow.py) Workflow: -This sample shows how to setup telemetry when using the Agent Framework's workflow execution engine. It demonstrates a simple workflow scenario with telemetry. - - -## Running the samples +### Running the samples 1. Open a terminal and navigate to this folder: `python/samples/getting_started/observability/`. This is necessary for the `.env` file to be read correctly. 2. Create a `.env` file if one doesn't already exist in this folder. Please refer to the [example file](./.env.example). > Note that `APPLICATIONINSIGHTS_CONNECTION_STRING` and `OTLP_ENDPOINT` are optional. If you don't configure them, everything will get outputted to the console. - > Set `ENABLE_OTEL=true` to enable telemetry and `ENABLE_SENSITIVE_DATA=true` to include sensitive information like prompts and responses. - > Sensitive information should only be enabled in a development or test environment. It is not recommended to enable this in production environments as it may expose sensitive data. -3. Activate your python virtual environment, and then run `python 01-zero_code.py` or others. -> This will also print the Operation/Trace ID, which can be used later for filtering. +3. Activate your python virtual environment, and then run `python setup_observability_with_env_vars.py` or others. + +> This will also print the Operation/Trace ID, which can be used later for filtering logs and traces in Application Insights or Aspire Dashboard. ## Application Insights/Azure Monitor @@ -85,7 +121,8 @@ You can connect to your Application Insights instance using a connection string. ```python from azure.identity import DefaultAzureCredential -setup_observability(credential=DefaultAzureCredential()) +# The credential will be for resources specified in the environment variables and the parameters passed in. +setup_observability(..., credential=DefaultAzureCredential()) ``` It is recommended to use [DefaultAzureCredential](https://learn.microsoft.com/en-us/python/api/azure-identity/azure.identity.defaultazurecredential?view=azure-python) for local development and [ManagedIdentityCredential](https://learn.microsoft.com/en-us/python/api/azure-identity/azure.identity.managedidentitycredential?view=azure-python) for production environments. diff --git a/python/samples/getting_started/observability/manual_setup_console_output.py b/python/samples/getting_started/observability/advanced_manual_setup_console_output.py similarity index 91% rename from python/samples/getting_started/observability/manual_setup_console_output.py rename to python/samples/getting_started/observability/advanced_manual_setup_console_output.py index 2a74e67f5a..6f3dc4403b 100644 --- a/python/samples/getting_started/observability/manual_setup_console_output.py +++ b/python/samples/getting_started/observability/advanced_manual_setup_console_output.py @@ -1,5 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -# type: ignore + import asyncio import logging from random import randint @@ -15,26 +15,25 @@ from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExpo from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter -from opentelemetry.semconv.resource import ResourceAttributes +from opentelemetry.semconv._incubating.attributes.service_attributes import SERVICE_NAME from opentelemetry.trace import set_tracer_provider from pydantic import Field """ -This sample shows how to manually set up OpenTelemetry to log to the console. -And this can also be used as a reference for more complex telemetry setups. +This sample shows how to manually configure to send traces, logs, and metrics to the console, +without using the `setup_observability` helper function. """ -resource = Resource.create({ResourceAttributes.SERVICE_NAME: "ManualSetup"}) +resource = Resource.create({SERVICE_NAME: "ManualSetup"}) -def setup_console_telemetry(): +def setup_logging(): # Create and set a global logger provider for the application. logger_provider = LoggerProvider(resource=resource) # Log processors are initialized with an exporter which is responsible logger_provider.add_log_record_processor(BatchLogRecordProcessor(ConsoleLogExporter())) # Sets the global default logger provider set_logger_provider(logger_provider) - # Create a logging handler to write logging records, in OTLP format, to the exporter. handler = LoggingHandler() # Attach the handler to the root logger. `getLogger()` with no arguments returns the root logger. @@ -43,6 +42,9 @@ def setup_console_telemetry(): logger.addHandler(handler) # Set the logging level to NOTSET to allow all records to be processed by the handler. logger.setLevel(logging.NOTSET) + + +def setup_tracing(): # Initialize a trace provider for the application. This is a factory for creating tracers. tracer_provider = TracerProvider(resource=resource) # Span processors are initialized with an exporter which is responsible @@ -50,6 +52,9 @@ def setup_console_telemetry(): tracer_provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter())) # Sets the global default tracer provider set_tracer_provider(tracer_provider) + + +def setup_metrics(): # Initialize a metric provider for the application. This is a factory for creating meters. meter_provider = MeterProvider( metric_readers=[PeriodicExportingMetricReader(ConsoleMetricExporter(), export_interval_millis=5000)], @@ -106,7 +111,10 @@ async def run_chat_client() -> None: async def main(): """Run the selected scenario(s).""" - setup_console_telemetry() + setup_logging() + setup_tracing() + setup_metrics() + await run_chat_client() diff --git a/python/samples/getting_started/observability/01-zero_code.py b/python/samples/getting_started/observability/advanced_zero_code.py similarity index 71% rename from python/samples/getting_started/observability/01-zero_code.py rename to python/samples/getting_started/observability/advanced_zero_code.py index 84e1f33fc7..c6d8665c4d 100644 --- a/python/samples/getting_started/observability/01-zero_code.py +++ b/python/samples/getting_started/observability/advanced_zero_code.py @@ -1,10 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. -# type: ignore + import asyncio from random import randint from typing import TYPE_CHECKING, Annotated +from agent_framework.observability import get_tracer from agent_framework.openai import OpenAIResponsesClient +from opentelemetry.trace import SpanKind +from opentelemetry.trace.span import format_trace_id from pydantic import Field if TYPE_CHECKING: @@ -12,13 +15,16 @@ if TYPE_CHECKING: """ -This is the simplest sample of using the Agent Framework with telemetry. +This sample shows how you can configure observability of an application with zero code changes. +It relies on the OpenTelemetry auto-instrumentation capabilities, and the observability setup +is done via environment variables. -This relies on the environment setting up the telemetry, you can test this with -by navigating to this folder and running: -uv run --env-file=zero_code.env opentelemetry-instrument python 01-zero_code.py +This sample requires the `APPLICATIONINSIGHTS_CONNECTION_STRING` environment variable to be set. -Check the zero_code.env file for the settings used in this example and to adapt it to your environment. +Run the sample with the following command: +``` +uv run --env-file=.env opentelemetry-instrument python advanced_zero_code.py +``` """ @@ -71,11 +77,13 @@ async def run_chat_client(client: "ChatClientProtocol", stream: bool = False) -> async def main() -> None: - client = OpenAIResponsesClient() + with get_tracer().start_as_current_span("Zero Code", kind=SpanKind.CLIENT) as current_span: + print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") - # Scenarios where telemetry is collected in the SDK, from the most basic to the most complex. - await run_chat_client(client, stream=True) - await run_chat_client(client, stream=False) + client = OpenAIResponsesClient() + + await run_chat_client(client, stream=True) + await run_chat_client(client, stream=False) if __name__ == "__main__": diff --git a/python/samples/getting_started/observability/03a-agent.py b/python/samples/getting_started/observability/agent_observability.py similarity index 76% rename from python/samples/getting_started/observability/03a-agent.py rename to python/samples/getting_started/observability/agent_observability.py index d4af86a22d..37e5b25161 100644 --- a/python/samples/getting_started/observability/03a-agent.py +++ b/python/samples/getting_started/observability/agent_observability.py @@ -1,5 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -# type: ignore + import asyncio from random import randint from typing import Annotated @@ -8,12 +8,12 @@ from agent_framework import ChatAgent from agent_framework.observability import get_tracer, setup_observability from agent_framework.openai import OpenAIChatClient from opentelemetry.trace import SpanKind +from opentelemetry.trace.span import format_trace_id from pydantic import Field """ -This sample shows you can can setup telemetry with a agent. -The agent invoke is a additional Semantic Convention that now -will wrap the calls made by the underlying chat client and tools. +This sample shows how you can observe an agent in Agent Framework by using the +same observability setup function. """ @@ -27,13 +27,15 @@ async def get_weather( async def main(): - # Set up the telemetry + # This will enable tracing and create the necessary tracing, logging and metrics providers + # based on environment variables. See the .env.example file for the available configuration options. + setup_observability() questions = ["What's the weather in Amsterdam?", "and in Paris, and which is better?", "Why is the sky blue?"] - setup_observability() - with get_tracer().start_as_current_span("Scenario: Agent Chat", kind=SpanKind.CLIENT): - print("Running scenario: Agent Chat") - print("Welcome to the chat, type 'exit' to quit.") + + with get_tracer().start_as_current_span("Scenario: Agent Chat", kind=SpanKind.CLIENT) as current_span: + print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") + agent = ChatAgent( chat_client=OpenAIChatClient(), tools=get_weather, diff --git a/python/samples/getting_started/observability/03b-azure_ai_agent.py b/python/samples/getting_started/observability/azure_ai_agent_observability.py similarity index 62% rename from python/samples/getting_started/observability/03b-azure_ai_agent.py rename to python/samples/getting_started/observability/azure_ai_agent_observability.py index fc050ccf09..514042a0c8 100644 --- a/python/samples/getting_started/observability/03b-azure_ai_agent.py +++ b/python/samples/getting_started/observability/azure_ai_agent_observability.py @@ -1,23 +1,33 @@ # Copyright (c) Microsoft. All rights reserved. -# type: ignore + import asyncio import os from random import randint from typing import Annotated +import dotenv from agent_framework import ChatAgent from agent_framework.azure import AzureAIAgentClient from agent_framework.observability import get_tracer from azure.ai.projects.aio import AIProjectClient from azure.identity.aio import AzureCliCredential from opentelemetry.trace import SpanKind +from opentelemetry.trace.span import format_trace_id from pydantic import Field """ -This sample shows you can can setup telemetry with a agent from Azure AI. -We once again call the `setup_observability` method to set up telemetry in order to include the overall spans. +This sample shows you can can setup telemetry for an Azure AI agent. +It uses the Azure AI client to setup the telemetry, this calls out to +Azure AI for the connection string of the attached Application Insights +instance. + +You must add an Application Insights instance to your Azure AI project +for this sample to work. """ +# For loading the `AZURE_AI_PROJECT_ENDPOINT` environment variable +dotenv.load_dotenv() + async def get_weather( location: Annotated[str, Field(description="The location to get the weather for.")], @@ -29,19 +39,21 @@ async def get_weather( async def main(): - # Set up the providers - # This must be done before any other telemetry calls - questions = ["What's the weather in Amsterdam?", "and in Paris, and which is better?", "Why is the sky blue?"] async with ( AzureCliCredential() as credential, AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project, - # this calls `setup_observability` through the context manager - AzureAIAgentClient(client=project) as client, + AzureAIAgentClient(project_client=project) as client, ): - await client.setup_observability(enable_live_metrics=True) - with get_tracer().start_as_current_span("Single Agent Chat", kind=SpanKind.CLIENT): - print("Running Single Agent Chat") - print("Welcome to the chat, type 'exit' to quit.") + # This will enable tracing and configure the application to send telemetry data to the + # Application Insights instance attached to the Azure AI project. + # This will override any existing configuration. + await client.setup_observability() + + questions = ["What's the weather in Amsterdam?", "and in Paris, and which is better?", "Why is the sky blue?"] + + with get_tracer().start_as_current_span("Single Agent Chat", kind=SpanKind.CLIENT) as current_span: + print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") + agent = ChatAgent( chat_client=client, tools=get_weather, diff --git a/python/samples/getting_started/observability/02b-azure_ai_chat_client.py b/python/samples/getting_started/observability/azure_ai_chat_client_with_observability.py similarity index 73% rename from python/samples/getting_started/observability/02b-azure_ai_chat_client.py rename to python/samples/getting_started/observability/azure_ai_chat_client_with_observability.py index 974f103ebc..24203616a5 100644 --- a/python/samples/getting_started/observability/02b-azure_ai_chat_client.py +++ b/python/samples/getting_started/observability/azure_ai_chat_client_with_observability.py @@ -1,13 +1,14 @@ # Copyright (c) Microsoft. All rights reserved. -# type: ignore + import asyncio import os from random import randint from typing import Annotated +import dotenv from agent_framework import HostedCodeInterpreterTool from agent_framework.azure import AzureAIAgentClient -from agent_framework.observability import get_tracer, setup_observability +from agent_framework.observability import get_tracer from azure.ai.projects.aio import AIProjectClient from azure.identity.aio import AzureCliCredential from opentelemetry.trace import SpanKind @@ -16,13 +17,16 @@ from pydantic import Field """ This sample, shows you can leverage the built-in telemetry in Azure AI. -It uses the Azure AI client to setup the telemetry, this calls -out to Azure AI for a telemetry connection strings, -and then call the setup_observability function in the agent framework. -If you want to compare with the trace sent to a generic OTLP endpoint, -switch the `use_azure_ai_telemetry` variable to False. +It uses the Azure AI client to setup the telemetry, this calls out to +Azure AI for the connection string of the attached Application Insights +instance. + +You must add an Application Insights instance to your Azure AI project +for this sample to work. """ +# For loading the `AZURE_AI_PROJECT_ENDPOINT` environment variable +dotenv.load_dotenv() # ANSI color codes for printing in blue and resetting after each print BLUE = "\x1b[34m" @@ -50,7 +54,6 @@ async def main() -> None: In azure_ai you will also see specific operations happening that are called by the Azure AI implementation, such as `create_agent`. """ - use_azure_ai_obs = True questions = [ "What's the weather in Amsterdam and in Paris?", "Why is the sky blue?", @@ -60,16 +63,18 @@ async def main() -> None: async with ( AzureCliCredential() as credential, AIProjectClient(endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], credential=credential) as project, - AzureAIAgentClient(client=project, setup_tracing=False) as client, + AzureAIAgentClient(project_client=project) as client, ): - if use_azure_ai_obs: - await client.setup_observability(enable_live_metrics=True) - else: - setup_observability() + # This will enable tracing and configure the application to send telemetry data to the + # Application Insights instance attached to the Azure AI project. + # This will override any existing configuration. + await client.setup_observability() with get_tracer().start_as_current_span( - name="Azure AI Telemetry from Agent Framework", kind=SpanKind.CLIENT - ) as span: + name="Foundry Telemetry from Agent Framework", kind=SpanKind.CLIENT + ) as current_span: + print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") + for question in questions: print(f"{BLUE}User: {question}{RESET}") print(f"{BLUE}Assistant: {RESET}", end="") @@ -81,7 +86,6 @@ async def main() -> None: print(f"{BLUE}{RESET}") print(f"{BLUE}Done{RESET}") - print(f"{BLUE}Operation ID: {format_trace_id(span.get_span_context().trace_id)}{RESET}") if __name__ == "__main__": diff --git a/python/samples/getting_started/observability/02a-generic_chat_client.py b/python/samples/getting_started/observability/setup_observability_with_env_var.py similarity index 86% rename from python/samples/getting_started/observability/02a-generic_chat_client.py rename to python/samples/getting_started/observability/setup_observability_with_env_var.py index 75e0347c93..53682ff924 100644 --- a/python/samples/getting_started/observability/02a-generic_chat_client.py +++ b/python/samples/getting_started/observability/setup_observability_with_env_var.py @@ -1,5 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -# type: ignore + import argparse import asyncio from contextlib import suppress @@ -17,13 +17,17 @@ if TYPE_CHECKING: from agent_framework import ChatClientProtocol """ -This sample, show how you can get telemetry from a chat client and tool. -it uses the `tracer` that is configured by agent framework, -which also sets up the traces with the configured environment. +This sample, show how you can configure observability of an application via the +`setup_observability` function with environment variables. + +When you run this sample with an OTLP endpoint or an Application Insights connection string, +you should see traces, logs, and metrics in the configured backend. + +If no OTLP endpoint or Application Insights connection string is configured, the sample will +output traces, logs, and metrics to the console. """ - -# Define the scenarios that can be run +# Define the scenarios that can be run to show the telemetry data collected by the SDK SCENARIOS = ["chat_client", "chat_client_stream", "ai_function", "all"] @@ -93,7 +97,11 @@ async def run_ai_function() -> None: async def main(scenario: Literal["chat_client", "chat_client_stream", "ai_function", "all"] = "all"): """Run the selected scenario(s).""" + + # This will enable tracing and create the necessary tracing, logging and metrics providers + # based on environment variables. See the .env.example file for the available configuration options. setup_observability() + with get_tracer().start_as_current_span("Sample Scenario's", kind=trace.SpanKind.CLIENT) as current_span: print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") diff --git a/python/samples/getting_started/observability/setup_observability_with_parameters.py b/python/samples/getting_started/observability/setup_observability_with_parameters.py new file mode 100644 index 0000000000..f152f7372c --- /dev/null +++ b/python/samples/getting_started/observability/setup_observability_with_parameters.py @@ -0,0 +1,140 @@ +# Copyright (c) Microsoft. All rights reserved. + +import argparse +import asyncio +from contextlib import suppress +from random import randint +from typing import TYPE_CHECKING, Annotated, Literal + +from agent_framework import ai_function +from agent_framework.observability import get_tracer, setup_observability +from agent_framework.openai import OpenAIResponsesClient +from opentelemetry import trace +from opentelemetry.trace.span import format_trace_id +from pydantic import Field + +if TYPE_CHECKING: + from agent_framework import ChatClientProtocol + +""" +This sample, show how you can configure observability of an application via the +`setup_observability` function and inline parameters. + +When you run this sample with an OTLP endpoint or an Application Insights connection string, +you should see traces, logs, and metrics in the configured backend. + +If no OTLP endpoint or Application Insights connection string is configured, the sample will +output traces, logs, and metrics to the console. +""" + +# Define the scenarios that can be run to show the telemetry data collected by the SDK +SCENARIOS = ["chat_client", "chat_client_stream", "ai_function", "all"] + + +async def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + await asyncio.sleep(randint(0, 10) / 10.0) # Simulate a network call + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +async def run_chat_client(client: "ChatClientProtocol", stream: bool = False) -> None: + """Run an AI service. + + This function runs an AI service and prints the output. + Telemetry will be collected for the service execution behind the scenes, + and the traces will be sent to the configured telemetry backend. + + The telemetry will include information about the AI service execution. + + Args: + client: The chat client to use. + stream: Whether to use streaming for the response + + Remarks: + For the scenario below, you should see the following: + 1 Client span, with 4 children: + 2 Internal span with gen_ai.operation.name=chat + The first has finish_reason "tool_calls" + The second has finish_reason "stop" + 2 Internal span with gen_ai.operation.name=execute_tool + + """ + scenario_name = "Chat Client Stream" if stream else "Chat Client" + with get_tracer().start_as_current_span(name=f"Scenario: {scenario_name}", kind=trace.SpanKind.CLIENT): + print("Running scenario:", scenario_name) + message = "What's the weather in Amsterdam and in Paris?" + print(f"User: {message}") + if stream: + print("Assistant: ", end="") + async for chunk in client.get_streaming_response(message, tools=get_weather): + if str(chunk): + print(str(chunk), end="") + print("") + else: + response = await client.get_response(message, tools=get_weather) + print(f"Assistant: {response}") + + +async def run_ai_function() -> None: + """Run a AI function. + + This function runs a AI function and prints the output. + Telemetry will be collected for the function execution behind the scenes, + and the traces will be sent to the configured telemetry backend. + + The telemetry will include information about the AI function execution + and the AI service execution. + """ + with get_tracer().start_as_current_span("Scenario: AI Function", kind=trace.SpanKind.CLIENT): + print("Running scenario: AI Function") + func = ai_function(get_weather) + weather = await func.invoke(location="Amsterdam") + print(f"Weather in Amsterdam:\n{weather}") + + +async def main(scenario: Literal["chat_client", "chat_client_stream", "ai_function", "all"] = "all"): + """Run the selected scenario(s).""" + + # This will enable tracing and create the necessary tracing, logging and metrics providers + # based on the provided parameters. + setup_observability( + enable_sensitive_data=True, + # If you have set the `OTLP_ENDPOINT` environment variable and it'd different from the one below, + # both endpoints will be used to create the OTLP exporter. + # Same applies to the Application Insights connection string. + otlp_endpoint=["http://localhost:4317/"], + ) + + with get_tracer().start_as_current_span("Sample Scenario's", kind=trace.SpanKind.CLIENT) as current_span: + print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") + + client = OpenAIResponsesClient() + + # Scenarios where telemetry is collected in the SDK, from the most basic to the most complex. + if scenario == "ai_function" or scenario == "all": + with suppress(Exception): + await run_ai_function() + if scenario == "chat_client_stream" or scenario == "all": + with suppress(Exception): + await run_chat_client(client, stream=True) + if scenario == "chat_client" or scenario == "all": + with suppress(Exception): + await run_chat_client(client, stream=False) + + +if __name__ == "__main__": + arg_parser = argparse.ArgumentParser() + + arg_parser.add_argument( + "--scenario", + type=str, + choices=SCENARIOS, + default="all", + help="The scenario to run. Default is all.", + ) + + args = arg_parser.parse_args() + asyncio.run(main(args.scenario)) diff --git a/python/samples/getting_started/observability/workflow_observability.py b/python/samples/getting_started/observability/workflow_observability.py new file mode 100644 index 0000000000..10e30024ef --- /dev/null +++ b/python/samples/getting_started/observability/workflow_observability.py @@ -0,0 +1,103 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import ( + Executor, + WorkflowBuilder, + WorkflowContext, + WorkflowOutputEvent, + handler, +) +from agent_framework.observability import get_tracer, setup_observability +from opentelemetry.trace import SpanKind +from opentelemetry.trace.span import format_trace_id +from typing_extensions import Never + +""" +This sample shows the telemetry collected when running a Agent Framework workflow. + +Telemetry data that the workflow system emits includes: +- Overall workflow build & execution spans +- Individual executor processing spans +- Message publishing between executors +""" + + +# Executors for sequential workflow +class UpperCaseExecutor(Executor): + """An executor that converts text to uppercase.""" + + @handler + async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None: + """Execute the task by converting the input string to uppercase.""" + print(f"UpperCaseExecutor: Processing '{text}'") + result = text.upper() + print(f"UpperCaseExecutor: Result '{result}'") + + # Send the result to the next executor in the workflow. + await ctx.send_message(result) + + +class ReverseTextExecutor(Executor): + """An executor that reverses text.""" + + @handler + async def reverse_text(self, text: str, ctx: WorkflowContext[Never, str]) -> None: + """Execute the task by reversing the input string.""" + print(f"ReverseTextExecutor: Processing '{text}'") + result = text[::-1] + print(f"ReverseTextExecutor: Result '{result}'") + + # Yield the output. + await ctx.yield_output(result) + + +async def run_sequential_workflow() -> None: + """Run a simple sequential workflow demonstrating telemetry collection. + + This workflow processes a string through two executors in sequence: + 1. UpperCaseExecutor converts the input to uppercase + 2. ReverseTextExecutor reverses the string and completes the workflow + """ + # Step 1: Create the executors. + upper_case_executor = UpperCaseExecutor(id="upper_case_executor") + reverse_text_executor = ReverseTextExecutor(id="reverse_text_executor") + + # Step 2: Build the workflow with the defined edges. + workflow = ( + WorkflowBuilder() + .add_edge(upper_case_executor, reverse_text_executor) + .set_start_executor(upper_case_executor) + .build() + ) + + # Step 3: Run the workflow with an initial message. + input_text = "hello world" + print(f"Starting workflow with input: '{input_text}'") + + output_event = None + async for event in workflow.run_stream(input_text): + if isinstance(event, WorkflowOutputEvent): + # The WorkflowOutputEvent contains the final result. + output_event = event + + if output_event: + print(f"Workflow completed with result: '{output_event.data}'") + + +async def main(): + """Run the telemetry sample with a simple sequential workflow.""" + # This will enable tracing and create the necessary tracing, logging and metrics providers + # based on environment variables. See the .env.example file for the available configuration options. + setup_observability() + + with get_tracer().start_as_current_span("Sequential Workflow Scenario", kind=SpanKind.CLIENT) as current_span: + print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}") + + # Run the sequential workflow scenario + await run_sequential_workflow() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/observability/zero_code.env b/python/samples/getting_started/observability/zero_code.env deleted file mode 100644 index 227bd28dfb..0000000000 --- a/python/samples/getting_started/observability/zero_code.env +++ /dev/null @@ -1,6 +0,0 @@ -ENABLE_OTEL=true -ENABLE_SENSITIVE_DATA=true -OTEL_SERVICE_NAME=agent_framework -OTEL_TRACES_EXPORTER=otlp -OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="http://localhost:4317/" -# APPLICATIONINSIGHTS_CONNECTION_STRING="" diff --git a/python/samples/getting_started/workflow/README.md b/python/samples/getting_started/workflow/README.md index 14ed675c01..500272ed12 100644 --- a/python/samples/getting_started/workflow/README.md +++ b/python/samples/getting_started/workflow/README.md @@ -31,6 +31,7 @@ Once comfortable with these, explore the rest of the samples below. ## Samples Overview (by directory) ### agents + | Sample | File | Concepts | |---|---|---| | Azure Chat Agents (Streaming) | [agents/azure_chat_agents_streaming.py](./agents/azure_chat_agents_streaming.py) | Add Azure agents as edges and handle streaming events | @@ -40,12 +41,14 @@ Once comfortable with these, explore the rest of the samples below. | Workflow as Agent + HITL | [agents/workflow_as_agent_human_in_the_loop.py](./agents/workflow_as_agent_human_in_the_loop.py) | Extend workflow-as-agent with human-in-the-loop capability | ### checkpoint + | Sample | File | Concepts | |---|---|---| | Checkpoint & Resume | [checkpoint/checkpoint_with_resume.py](./checkpoint/checkpoint_with_resume.py) | Create checkpoints, inspect them, and resume execution | | Checkpoint & HITL Resume | [checkpoint/checkpoint_with_human_in_the_loop.py](./checkpoint/checkpoint_with_human_in_the_loop.py) | Combine checkpointing with human approvals and resume pending HITL requests | ### composition + | Sample | File | Concepts | |---|---|---| | Sub-Workflow (Basics) | [composition/sub_workflow_basics.py](./composition/sub_workflow_basics.py) | Wrap a workflow as an executor and orchestrate sub-workflows | @@ -53,6 +56,7 @@ Once comfortable with these, explore the rest of the samples below. | Sub-Workflow: Parallel Requests | [composition/sub_workflow_parallel_requests.py](./composition/sub_workflow_parallel_requests.py) | Multiple specialized interceptors handling different request types from same sub-workflow | ### control-flow + | Sample | File | Concepts | |---|---|---| | Sequential Executors | [control-flow/sequential_executors.py](./control-flow/sequential_executors.py) | Sequential workflow with explicit executor setup | @@ -63,16 +67,19 @@ Once comfortable with these, explore the rest of the samples below. | Simple Loop | [control-flow/simple_loop.py](./control-flow/simple_loop.py) | Feedback loop where an agent judges ABOVE/BELOW/MATCHED | ### human-in-the-loop + | Sample | File | Concepts | |---|---|---| | Human-In-The-Loop (Guessing Game) | [human-in-the-loop/guessing_game_with_human_input.py](./human-in-the-loop/guessing_game_with_human_input.py) | Interactive request/response prompts with a human | ### observability + | Sample | File | Concepts | |---|---|---| -| Tracing (Basics) | [observability/tracing_basics.py](./observability/tracing_basics.py) | Use basic tracing for workflow telemetry | +| Tracing (Basics) | [observability/tracing_basics.py](./observability/tracing_basics.py) | Use basic tracing for workflow telemetry. Refer to this [directory](../observability/) to learn more about observability concepts. | ### orchestration + | Sample | File | Concepts | |---|---|---| | Concurrent Orchestration (Default Aggregator) | [orchestration/concurrent_agents.py](./orchestration/concurrent_agents.py) | Fan-out to multiple agents; fan-in with default aggregator returning combined ChatMessages | @@ -84,6 +91,7 @@ Once comfortable with these, explore the rest of the samples below. | Sequential Orchestration (Custom Executor) | [orchestration/sequential_custom_executors.py](./orchestration/sequential_custom_executors.py) | Mix agents with a summarizer that appends a compact summary | ### parallelism + | Sample | File | Concepts | |---|---|---| | Concurrent (Fan-out/Fan-in) | [parallelism/fan_out_fan_in_edges.py](./parallelism/fan_out_fan_in_edges.py) | Dispatch to multiple executors and aggregate results | @@ -91,16 +99,19 @@ Once comfortable with these, explore the rest of the samples below. | Map-Reduce with Visualization | [parallelism/map_reduce_and_visualization.py](./parallelism/map_reduce_and_visualization.py) | Fan-out/fan-in pattern with diagram export | ### state-management + | Sample | File | Concepts | |---|---|---| | Shared States | [state-management/shared_states_with_agents.py](./state-management/shared_states_with_agents.py) | Store in shared state once and later reuse across agents | ### visualization + | Sample | File | Concepts | |---|---|---| | Concurrent with Visualization | [visualization/concurrent_with_visualization.py](./visualization/concurrent_with_visualization.py) | Fan-out/fan-in workflow with diagram export | ### resources + - Sample text inputs used by certain workflows: - [resources/long_text.txt](./resources/long_text.txt) - [resources/email.txt](./resources/email.txt) @@ -108,9 +119,11 @@ Once comfortable with these, explore the rest of the samples below. - [resources/ambiguous_email.txt](./resources/ambiguous_email.txt) Notes + - Agent-based samples use provider SDKs (Azure/OpenAI, etc.). Ensure credentials are configured, or adapt agents accordingly. Sequential orchestration uses a few small adapter nodes for plumbing: + - "input-conversation" normalizes input to `list[ChatMessage]` - "to-conversation:" converts agent responses into the shared conversation - "complete" publishes the final `WorkflowOutputEvent` diff --git a/python/samples/getting_started/workflow/observability/tracing_basics.py b/python/samples/getting_started/workflow/observability/tracing_basics.py index fd1daedc9b..6914ad4ce2 100644 --- a/python/samples/getting_started/workflow/observability/tracing_basics.py +++ b/python/samples/getting_started/workflow/observability/tracing_basics.py @@ -1,9 +1,9 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -import os from agent_framework import Executor, WorkflowBuilder, WorkflowContext, get_logger, handler +from agent_framework.observability import setup_observability """Basic tracing workflow sample. @@ -23,30 +23,11 @@ Purpose: Prerequisites: - No external services required for the workflow itself. -- To print spans to the console, install the OpenTelemetry SDK: pip install opentelemetry-sdk -- Enable diagnostics: - configure your .env file with `ENABLE_OTEL=true` or run: - export ENABLE_OTEL=true """ logger = get_logger() -def _ensure_tracing_configured() -> None: - """Fail fast unless diagnostics are enabled and the SDK is present. - - If the env var is set, attach a ConsoleSpanExporter so spans print to stdout. - """ - env = os.getenv("ENABLE_OTEL", "").lower() - if env not in {"1", "true", "yes"}: - logger.info("Tracing diagnostics are disabled in the env. Setting this manually here.") - - from agent_framework.observability import setup_observability - from opentelemetry.sdk.trace.export import ConsoleSpanExporter - - setup_observability(exporters=[ConsoleSpanExporter()]) - - class StartExecutor(Executor): @handler # type: ignore[misc] async def handle_input(self, message: str, ctx: WorkflowContext[str]) -> None: @@ -62,7 +43,9 @@ class EndExecutor(Executor): async def main() -> None: - _ensure_tracing_configured() # Enforce tracing configuration before building or running the workflow. + # This will enable tracing and create the necessary tracing, logging and metrics providers + # based on environment variables. + setup_observability() # Build a two node graph: StartExecutor -> EndExecutor. The builder emits a workflow.build span. workflow = ( diff --git a/python/uv.lock b/python/uv.lock index 0d2c099c92..830bfccf46 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -544,14 +544,14 @@ wheels = [ [[package]] name = "asgiref" -version = "3.9.1" +version = "3.9.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/61/0aa957eec22ff70b830b22ff91f825e70e1ef732c06666a805730f28b36b/asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", size = 36870, upload-time = "2025-07-08T09:07:43.344Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/bf/0f3ecda32f1cb3bf1dca480aca08a7a8a3bdc4bed2343a103f30731565c9/asgiref-3.9.2.tar.gz", hash = "sha256:a0249afacb66688ef258ffe503528360443e2b9a8d8c4581b6ebefa58c841ef1", size = 36894, upload-time = "2025-09-23T15:00:55.136Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/3c/0464dcada90d5da0e71018c04a140ad6349558afb30b3051b4264cc5b965/asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c", size = 23790, upload-time = "2025-07-08T09:07:41.548Z" }, + { url = "https://files.pythonhosted.org/packages/c7/d1/69d02ce34caddb0a7ae088b84c356a625a93cd4ff57b2f97644c03fad905/asgiref-3.9.2-py3-none-any.whl", hash = "sha256:0b61526596219d70396548fc003635056856dba5d0d086f86476f10b33c75960", size = 23788, upload-time = "2025-09-23T15:00:53.627Z" }, ] [[package]] @@ -1338,7 +1338,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -2159,7 +2159,7 @@ wheels = [ [[package]] name = "langfuse" -version = "3.5.0" +version = "3.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2172,14 +2172,14 @@ dependencies = [ { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/5d/edcaa7d3445588b13c28eeb1aeadbfd8c4483c1f241f57e2975f46e29b15/langfuse-3.5.0.tar.gz", hash = "sha256:e68b8e960494f360d57b2feb8b11e01a3e55a0722f39ba8bf54ed4a069a386d5", size = 187504, upload-time = "2025-09-18T16:56:57.652Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/7e/1d63ebcd38d64d05588bf48d52882871ba3b4f851bb8c3ff52c40285f5b4/langfuse-3.5.1.tar.gz", hash = "sha256:996ba858df3220f447da9dc90637721725e837de53c47b5276fa1e755e65e5b3", size = 187563, upload-time = "2025-09-25T11:35:16.369Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/58/108730232d55d321a576981aa6c8ee53d57b782a5002ae7bf7ef1b5b45b3/langfuse-3.5.0-py3-none-any.whl", hash = "sha256:946b02f1f4cbe3ace88860832e73bb8e9548575dcd27b8cbdb30b7b2fdf62cf8", size = 348539, upload-time = "2025-09-18T16:56:55.639Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6d/88d38794d9c5fb92c3236855ba4286ca1546c72a8b3a3a9d1bdf63712a0b/langfuse-3.5.1-py3-none-any.whl", hash = "sha256:10f9224c21babe9015b320058bcade6b4279d63da5182de08a76caf18cb8d552", size = 348549, upload-time = "2025-09-25T11:35:14.238Z" }, ] [[package]] name = "litellm" -version = "1.77.3" +version = "1.77.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2196,9 +2196,9 @@ dependencies = [ { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/86/8bfd372d3d437b773b4b81d6da35674a569c10a9b805409257790e3af271/litellm-1.77.3.tar.gz", hash = "sha256:d8f9d674ef4e7673b1af02428fde27de5a8e84ca7268f003902340586aac7d96", size = 10314535, upload-time = "2025-09-21T00:59:09.655Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/b7/0d3c6dbcff3064238d123f90ae96764a85352f3f5caab6695a55007fd019/litellm-1.77.4.tar.gz", hash = "sha256:ce652e10ecf5b36767bfdf58e53b2802e22c3de383b03554e6ee1a4a66fa743d", size = 10330773, upload-time = "2025-09-24T17:52:44.876Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/b2/122602255b582fdcf630f8e44b5c9175391abe10be5e2f4db6a7d4173df1/litellm-1.77.3-py3-none-any.whl", hash = "sha256:f0c8c6bcfa2c9cd9e9fa0304f9a94894d252e7c74f118c37a8f2e4e525b2592b", size = 9118886, upload-time = "2025-09-21T00:59:06.178Z" }, + { url = "https://files.pythonhosted.org/packages/3c/32/90f8587818d146d604ed6eec95f96378363fda06b14817399cc68853383e/litellm-1.77.4-py3-none-any.whl", hash = "sha256:66c2bb776f1e19ceddfa977a2bbf7f05e6f26c4b1fec8b2093bd171d842701b8", size = 9138493, upload-time = "2025-09-24T17:52:40.764Z" }, ] [[package]] @@ -2367,7 +2367,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.14.1" +version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2382,9 +2382,9 @@ dependencies = [ { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "uvicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/e9/242096400d702924b49f8d202c6ded7efb8841cacba826b5d2e6183aef7b/mcp-1.14.1.tar.gz", hash = "sha256:31c4406182ba15e8f30a513042719c3f0a38c615e76188ee5a736aaa89e20134", size = 454944, upload-time = "2025-09-18T13:37:19.971Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/9e/e65114795f359f314d7061f4fcb50dfe60026b01b52ad0b986b4631bf8bb/mcp-1.15.0.tar.gz", hash = "sha256:5bda1f4d383cf539d3c035b3505a3de94b20dbd7e4e8b4bd071e14634eeb2d72", size = 469622, upload-time = "2025-09-25T15:39:51.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/11/d334fbb7c2aeddd2e762b86d7a619acffae012643a5738e698f975a2a9e2/mcp-1.14.1-py3-none-any.whl", hash = "sha256:3b7a479e8e5cbf5361bdc1da8bc6d500d795dc3aff44b44077a363a7f7e945a4", size = 163809, upload-time = "2025-09-18T13:37:18.165Z" }, + { url = "https://files.pythonhosted.org/packages/c9/82/4d0df23d5ff5bb982a59ad597bc7cb9920f2650278ccefb8e0d85c5ce3d4/mcp-1.15.0-py3-none-any.whl", hash = "sha256:314614c8addc67b663d6c3e4054db0a5c3dedc416c24ef8ce954e203fdc2333d", size = 166963, upload-time = "2025-09-25T15:39:50.538Z" }, ] [package.optional-dependencies] @@ -3845,16 +3845,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.10.1" +version = "2.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/20/c5/dbbc27b814c71676593d1c3f718e6cd7d4f00652cefa24b75f7aa3efb25e/pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180", size = 188394, upload-time = "2025-09-24T14:19:11.764Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, + { url = "https://files.pythonhosted.org/packages/83/d6/887a1ff844e64aa823fb4905978d882a633cfe295c32eacad582b78a7d8b/pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c", size = 48608, upload-time = "2025-09-24T14:19:10.015Z" }, ] [[package]] @@ -4455,28 +4455,28 @@ wheels = [ [[package]] name = "ruff" -version = "0.13.1" +version = "0.13.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ab/33/c8e89216845615d14d2d42ba2bee404e7206a8db782f33400754f3799f05/ruff-0.13.1.tar.gz", hash = "sha256:88074c3849087f153d4bb22e92243ad4c1b366d7055f98726bc19aa08dc12d51", size = 5397987, upload-time = "2025-09-18T19:52:44.33Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/df/8d7d8c515d33adfc540e2edf6c6021ea1c5a58a678d8cfce9fae59aabcab/ruff-0.13.2.tar.gz", hash = "sha256:cb12fffd32fb16d32cef4ed16d8c7cdc27ed7c944eaa98d99d01ab7ab0b710ff", size = 5416417, upload-time = "2025-09-25T14:54:09.936Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/41/ca37e340938f45cfb8557a97a5c347e718ef34702546b174e5300dbb1f28/ruff-0.13.1-py3-none-linux_armv6l.whl", hash = "sha256:b2abff595cc3cbfa55e509d89439b5a09a6ee3c252d92020bd2de240836cf45b", size = 12304308, upload-time = "2025-09-18T19:51:56.253Z" }, - { url = "https://files.pythonhosted.org/packages/ff/84/ba378ef4129415066c3e1c80d84e539a0d52feb250685091f874804f28af/ruff-0.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4ee9f4249bf7f8bb3984c41bfaf6a658162cdb1b22e3103eabc7dd1dc5579334", size = 12937258, upload-time = "2025-09-18T19:52:00.184Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b6/ec5e4559ae0ad955515c176910d6d7c93edcbc0ed1a3195a41179c58431d/ruff-0.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c5da4af5f6418c07d75e6f3224e08147441f5d1eac2e6ce10dcce5e616a3bae", size = 12214554, upload-time = "2025-09-18T19:52:02.753Z" }, - { url = "https://files.pythonhosted.org/packages/70/d6/cb3e3b4f03b9b0c4d4d8f06126d34b3394f6b4d764912fe80a1300696ef6/ruff-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80524f84a01355a59a93cef98d804e2137639823bcee2931f5028e71134a954e", size = 12448181, upload-time = "2025-09-18T19:52:05.279Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ea/bf60cb46d7ade706a246cd3fb99e4cfe854efa3dfbe530d049c684da24ff/ruff-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff7f5ce8d7988767dd46a148192a14d0f48d1baea733f055d9064875c7d50389", size = 12104599, upload-time = "2025-09-18T19:52:07.497Z" }, - { url = "https://files.pythonhosted.org/packages/2d/3e/05f72f4c3d3a69e65d55a13e1dd1ade76c106d8546e7e54501d31f1dc54a/ruff-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c55d84715061f8b05469cdc9a446aa6c7294cd4bd55e86a89e572dba14374f8c", size = 13791178, upload-time = "2025-09-18T19:52:10.189Z" }, - { url = "https://files.pythonhosted.org/packages/81/e7/01b1fc403dd45d6cfe600725270ecc6a8f8a48a55bc6521ad820ed3ceaf8/ruff-0.13.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ac57fed932d90fa1624c946dc67a0a3388d65a7edc7d2d8e4ca7bddaa789b3b0", size = 14814474, upload-time = "2025-09-18T19:52:12.866Z" }, - { url = "https://files.pythonhosted.org/packages/fa/92/d9e183d4ed6185a8df2ce9faa3f22e80e95b5f88d9cc3d86a6d94331da3f/ruff-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c366a71d5b4f41f86a008694f7a0d75fe409ec298685ff72dc882f882d532e36", size = 14217531, upload-time = "2025-09-18T19:52:15.245Z" }, - { url = "https://files.pythonhosted.org/packages/3b/4a/6ddb1b11d60888be224d721e01bdd2d81faaf1720592858ab8bac3600466/ruff-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4ea9d1b5ad3e7a83ee8ebb1229c33e5fe771e833d6d3dcfca7b77d95b060d38", size = 13265267, upload-time = "2025-09-18T19:52:17.649Z" }, - { url = "https://files.pythonhosted.org/packages/81/98/3f1d18a8d9ea33ef2ad508f0417fcb182c99b23258ec5e53d15db8289809/ruff-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0f70202996055b555d3d74b626406476cc692f37b13bac8828acff058c9966a", size = 13243120, upload-time = "2025-09-18T19:52:20.332Z" }, - { url = "https://files.pythonhosted.org/packages/8d/86/b6ce62ce9c12765fa6c65078d1938d2490b2b1d9273d0de384952b43c490/ruff-0.13.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f8cff7a105dad631085d9505b491db33848007d6b487c3c1979dd8d9b2963783", size = 13443084, upload-time = "2025-09-18T19:52:23.032Z" }, - { url = "https://files.pythonhosted.org/packages/a1/6e/af7943466a41338d04503fb5a81b2fd07251bd272f546622e5b1599a7976/ruff-0.13.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9761e84255443316a258dd7dfbd9bfb59c756e52237ed42494917b2577697c6a", size = 12295105, upload-time = "2025-09-18T19:52:25.263Z" }, - { url = "https://files.pythonhosted.org/packages/3f/97/0249b9a24f0f3ebd12f007e81c87cec6d311de566885e9309fcbac5b24cc/ruff-0.13.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3d376a88c3102ef228b102211ef4a6d13df330cb0f5ca56fdac04ccec2a99700", size = 12072284, upload-time = "2025-09-18T19:52:27.478Z" }, - { url = "https://files.pythonhosted.org/packages/f6/85/0b64693b2c99d62ae65236ef74508ba39c3febd01466ef7f354885e5050c/ruff-0.13.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cbefd60082b517a82c6ec8836989775ac05f8991715d228b3c1d86ccc7df7dae", size = 12970314, upload-time = "2025-09-18T19:52:30.212Z" }, - { url = "https://files.pythonhosted.org/packages/96/fc/342e9f28179915d28b3747b7654f932ca472afbf7090fc0c4011e802f494/ruff-0.13.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd16b9a5a499fe73f3c2ef09a7885cb1d97058614d601809d37c422ed1525317", size = 13422360, upload-time = "2025-09-18T19:52:32.676Z" }, - { url = "https://files.pythonhosted.org/packages/37/54/6177a0dc10bce6f43e392a2192e6018755473283d0cf43cc7e6afc182aea/ruff-0.13.1-py3-none-win32.whl", hash = "sha256:55e9efa692d7cb18580279f1fbb525146adc401f40735edf0aaeabd93099f9a0", size = 12178448, upload-time = "2025-09-18T19:52:35.545Z" }, - { url = "https://files.pythonhosted.org/packages/64/51/c6a3a33d9938007b8bdc8ca852ecc8d810a407fb513ab08e34af12dc7c24/ruff-0.13.1-py3-none-win_amd64.whl", hash = "sha256:3a3fb595287ee556de947183489f636b9f76a72f0fa9c028bdcabf5bab2cc5e5", size = 13286458, upload-time = "2025-09-18T19:52:38.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/04/afc078a12cf68592345b1e2d6ecdff837d286bac023d7a22c54c7a698c5b/ruff-0.13.1-py3-none-win_arm64.whl", hash = "sha256:c0bae9ffd92d54e03c2bf266f466da0a65e145f298ee5b5846ed435f6a00518a", size = 12437893, upload-time = "2025-09-18T19:52:41.283Z" }, + { url = "https://files.pythonhosted.org/packages/6e/84/5716a7fa4758e41bf70e603e13637c42cfb9dbf7ceb07180211b9bbf75ef/ruff-0.13.2-py3-none-linux_armv6l.whl", hash = "sha256:3796345842b55f033a78285e4f1641078f902020d8450cade03aad01bffd81c3", size = 12343254, upload-time = "2025-09-25T14:53:27.784Z" }, + { url = "https://files.pythonhosted.org/packages/9b/77/c7042582401bb9ac8eff25360e9335e901d7a1c0749a2b28ba4ecb239991/ruff-0.13.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ff7e4dda12e683e9709ac89e2dd436abf31a4d8a8fc3d89656231ed808e231d2", size = 13040891, upload-time = "2025-09-25T14:53:31.38Z" }, + { url = "https://files.pythonhosted.org/packages/c6/15/125a7f76eb295cb34d19c6778e3a82ace33730ad4e6f28d3427e134a02e0/ruff-0.13.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75e9d2a2fafd1fdd895d0e7e24b44355984affdde1c412a6f6d3f6e16b22d46", size = 12243588, upload-time = "2025-09-25T14:53:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/9e/eb/0093ae04a70f81f8be7fd7ed6456e926b65d238fc122311293d033fdf91e/ruff-0.13.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cceac74e7bbc53ed7d15d1042ffe7b6577bf294611ad90393bf9b2a0f0ec7cb6", size = 12491359, upload-time = "2025-09-25T14:53:35.892Z" }, + { url = "https://files.pythonhosted.org/packages/43/fe/72b525948a6956f07dad4a6f122336b6a05f2e3fd27471cea612349fedb9/ruff-0.13.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae3f469b5465ba6d9721383ae9d49310c19b452a161b57507764d7ef15f4b07", size = 12162486, upload-time = "2025-09-25T14:53:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e3/0fac422bbbfb2ea838023e0d9fcf1f30183d83ab2482800e2cb892d02dfe/ruff-0.13.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f8f9e3cd6714358238cd6626b9d43026ed19c0c018376ac1ef3c3a04ffb42d8", size = 13871203, upload-time = "2025-09-25T14:53:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/6b/82/b721c8e3ec5df6d83ba0e45dcf00892c4f98b325256c42c38ef136496cbf/ruff-0.13.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c6ed79584a8f6cbe2e5d7dbacf7cc1ee29cbdb5df1172e77fbdadc8bb85a1f89", size = 14929635, upload-time = "2025-09-25T14:53:43.953Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a0/ad56faf6daa507b83079a1ad7a11694b87d61e6bf01c66bd82b466f21821/ruff-0.13.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aed130b2fde049cea2019f55deb939103123cdd191105f97a0599a3e753d61b0", size = 14338783, upload-time = "2025-09-25T14:53:46.205Z" }, + { url = "https://files.pythonhosted.org/packages/47/77/ad1d9156db8f99cd01ee7e29d74b34050e8075a8438e589121fcd25c4b08/ruff-0.13.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1887c230c2c9d65ed1b4e4cfe4d255577ea28b718ae226c348ae68df958191aa", size = 13355322, upload-time = "2025-09-25T14:53:48.164Z" }, + { url = "https://files.pythonhosted.org/packages/64/8b/e87cfca2be6f8b9f41f0bb12dc48c6455e2d66df46fe61bb441a226f1089/ruff-0.13.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bcb10276b69b3cfea3a102ca119ffe5c6ba3901e20e60cf9efb53fa417633c3", size = 13354427, upload-time = "2025-09-25T14:53:50.486Z" }, + { url = "https://files.pythonhosted.org/packages/7f/df/bf382f3fbead082a575edb860897287f42b1b3c694bafa16bc9904c11ed3/ruff-0.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:afa721017aa55a555b2ff7944816587f1cb813c2c0a882d158f59b832da1660d", size = 13537637, upload-time = "2025-09-25T14:53:52.887Z" }, + { url = "https://files.pythonhosted.org/packages/51/70/1fb7a7c8a6fc8bd15636288a46e209e81913b87988f26e1913d0851e54f4/ruff-0.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dbc875cf3720c64b3990fef8939334e74cb0ca65b8dbc61d1f439201a38101b", size = 12340025, upload-time = "2025-09-25T14:53:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/27/1e5b3f1c23ca5dd4106d9d580e5c13d9acb70288bff614b3d7b638378cc9/ruff-0.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939a1b2a960e9742e9a347e5bbc9b3c3d2c716f86c6ae273d9cbd64f193f22", size = 12133449, upload-time = "2025-09-25T14:53:57.089Z" }, + { url = "https://files.pythonhosted.org/packages/2d/09/b92a5ccee289f11ab128df57d5911224197d8d55ef3bd2043534ff72ca54/ruff-0.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:50e2d52acb8de3804fc5f6e2fa3ae9bdc6812410a9e46837e673ad1f90a18736", size = 13051369, upload-time = "2025-09-25T14:53:59.124Z" }, + { url = "https://files.pythonhosted.org/packages/89/99/26c9d1c7d8150f45e346dc045cc49f23e961efceb4a70c47dea0960dea9a/ruff-0.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3196bc13ab2110c176b9a4ae5ff7ab676faaa1964b330a1383ba20e1e19645f2", size = 13523644, upload-time = "2025-09-25T14:54:01.622Z" }, + { url = "https://files.pythonhosted.org/packages/f7/00/e7f1501e81e8ec290e79527827af1d88f541d8d26151751b46108978dade/ruff-0.13.2-py3-none-win32.whl", hash = "sha256:7c2a0b7c1e87795fec3404a485096bcd790216c7c146a922d121d8b9c8f1aaac", size = 12245990, upload-time = "2025-09-25T14:54:03.647Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bd/d9f33a73de84fafd0146c6fba4f497c4565fe8fa8b46874b8e438869abc2/ruff-0.13.2-py3-none-win_amd64.whl", hash = "sha256:17d95fb32218357c89355f6f6f9a804133e404fc1f65694372e02a557edf8585", size = 13324004, upload-time = "2025-09-25T14:54:06.05Z" }, + { url = "https://files.pythonhosted.org/packages/c3/12/28fa2f597a605884deb0f65c1b1ae05111051b2a7030f5d8a4ff7f4599ba/ruff-0.13.2-py3-none-win_arm64.whl", hash = "sha256:da711b14c530412c827219312b7d7fbb4877fb31150083add7e8c5336549cea7", size = 12484437, upload-time = "2025-09-25T14:54:08.022Z" }, ] [[package]] @@ -5100,28 +5100,28 @@ wheels = [ [[package]] name = "uv" -version = "0.8.20" +version = "0.8.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/5c/285626e2fa4f89d3111aba48dd1a3b940a7fc21b3e0b0595c90fd15bc216/uv-0.8.20.tar.gz", hash = "sha256:9560b87a25b05a2487a2a97ba944df22e565d7c0c8586f78152b867970fa1329", size = 3659652, upload-time = "2025-09-22T23:02:24.601Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/39/231e123458d50dd497cf6d27b592f5d3bc3e2e50f496b56859865a7b22e3/uv-0.8.22.tar.gz", hash = "sha256:e6e1289c411d43e0ca245f46e76457f3807de646d90b656591b6cf46348bed5c", size = 3667007, upload-time = "2025-09-23T20:35:14.736Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/19/061b2f23ad3ac4a2bebd1c934623821c62ccfb454385a052c2f8889a3592/uv-0.8.20-py3-none-linux_armv6l.whl", hash = "sha256:46798fad22dfcc1ba66c4e98faddbf15e3878255345049f5b599879caf52b01a", size = 20270849, upload-time = "2025-09-22T23:01:12.315Z" }, - { url = "https://files.pythonhosted.org/packages/c9/c5/80b0c091c56da5a709c01bebf9fd2f8d1fd553289e5ec0ba1783cd2a7d94/uv-0.8.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4f9941c625c12abaeb619c693b37007082e975455d149f32bb12068535af043e", size = 19282587, upload-time = "2025-09-22T23:01:18.162Z" }, - { url = "https://files.pythonhosted.org/packages/04/5f/5f5407a0bdd9309ad9abb96633055c721c5faa1cb11d21a2568b8dc79626/uv-0.8.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:94f30a025cbdd82f6212b0297c47e63e56cc57a5fb6f4e74853554f19ac9f6fa", size = 17908187, upload-time = "2025-09-22T23:01:21.907Z" }, - { url = "https://files.pythonhosted.org/packages/65/5b/40b874db599c016586a5becee6064b6502aeb07f7150a685ea2b4f0e1cac/uv-0.8.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:be6a69919241d4e953cfd7f601a64012649cd69cf66d499474bc10fc4f4dabb0", size = 19697193, upload-time = "2025-09-22T23:01:25.91Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ca/b8ff6f97d649f5b74183b6a0ca9d59744086ee8f873034969110da4c4cbf/uv-0.8.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:342ff059c4866c2dcefa829acf75b47cf0a03201769941afab5c774424a902ab", size = 19840725, upload-time = "2025-09-22T23:01:29.703Z" }, - { url = "https://files.pythonhosted.org/packages/07/0d/7fc078cead61c472ede432f931b6010be70daf8682b04f0c86911f76908b/uv-0.8.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edb79a6948712285220f21120dc740648e9ee640f7782be811b620895a4af7fc", size = 20820489, upload-time = "2025-09-22T23:01:33.446Z" }, - { url = "https://files.pythonhosted.org/packages/7f/df/572035306469de778365f25ae6926fa55ebfb87bc93144c6635877fda2e8/uv-0.8.20-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d3a872a87fdd69b46f865f9743dfdc51850f7b5d64cda48b4eb8a9ebb792df26", size = 22250316, upload-time = "2025-09-22T23:01:37.183Z" }, - { url = "https://files.pythonhosted.org/packages/6d/c8/9b475b7fe01f2ef0375ce94e0643ed9606fde63a621b96bddca22a4eabff/uv-0.8.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0024e6c5fdc633ee2659a3bcd01db4bb5e828c87322af2f96a6cd9dbd8fbb202", size = 21896353, upload-time = "2025-09-22T23:01:41.085Z" }, - { url = "https://files.pythonhosted.org/packages/04/f4/6165da453076a18caa2d2f892e661071225566248df6c5a20428273d2282/uv-0.8.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2eab535373ef1d81cd5181e267b14bfc53ad28e39de6d222dcb118c163386902", size = 21073526, upload-time = "2025-09-22T23:01:44.663Z" }, - { url = "https://files.pythonhosted.org/packages/bb/74/5a4996aa94346ffa4930af06e8c36117880d1e19c8695f820f279f3280eb/uv-0.8.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f70877df868b56e408883bad8e5bfa68ffc27865af181e5ee499074b364f571", size = 20967130, upload-time = "2025-09-22T23:01:48.337Z" }, - { url = "https://files.pythonhosted.org/packages/a9/7f/199a1a9f41eb0dba2f34f2f69008f3939d591747c90ad23406a551d61a1d/uv-0.8.20-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:59d70332b42a7c8dd5b8ecce54c05ffa126bd4eafebd7ededf347b915f2e4263", size = 19813405, upload-time = "2025-09-22T23:01:52.454Z" }, - { url = "https://files.pythonhosted.org/packages/4f/da/20e559b743f961590319435874e32267280795e112657b7edbfaf9ed3662/uv-0.8.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:40d749a856867bc7e0fa9314c06cc8e91584122e33e90c594f298d777c9acd51", size = 20887613, upload-time = "2025-09-22T23:01:56.688Z" }, - { url = "https://files.pythonhosted.org/packages/47/dc/52b4ad48e812cade9122e742e8a4249ccf0c9071f1b5ea26e3ad5d0bb7b6/uv-0.8.20-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:a39a90083cd7d5639affe77f969f80d93153ba7b82962f62ea30a944634f90dc", size = 19848883, upload-time = "2025-09-22T23:02:00.395Z" }, - { url = "https://files.pythonhosted.org/packages/db/fd/b61612e4bb87a3b583dfd7906dcebb9f3b3f3ca27a4b462301774d0dd503/uv-0.8.20-py3-none-musllinux_1_1_i686.whl", hash = "sha256:d5994d5cb4a6b327de657511825cb9e14d8fc1c7518d2cb01eeec70000f575ed", size = 20234284, upload-time = "2025-09-22T23:02:03.886Z" }, - { url = "https://files.pythonhosted.org/packages/68/55/e011ede284e0a1f1e38e27549c0a80f2bf44b1321003c1ddffc2d76110b3/uv-0.8.20-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:7d01cbe0a856f72a2505c6ac3ca4b9fafc304df664a00f27c9d3e4ddb085a844", size = 21139219, upload-time = "2025-09-22T23:02:09.099Z" }, - { url = "https://files.pythonhosted.org/packages/21/ab/aba7b572bf4066f3046f9143c28c58a833fafd3b0a16951da11739e3c6c2/uv-0.8.20-py3-none-win32.whl", hash = "sha256:86ce315cce8be58e24a88448f42649760aa29cc429691638f281337b68fe20b3", size = 19033345, upload-time = "2025-09-22T23:02:12.67Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9c/50ad29d3697bb85ba09e47d2d3ebf7ef694320c8054c2513f641782da96e/uv-0.8.20-py3-none-win_amd64.whl", hash = "sha256:e356e987b14779c957bc402abf5d917b0ff6c53b4153ad0271a00eef46a9b65a", size = 21082152, upload-time = "2025-09-22T23:02:17.534Z" }, - { url = "https://files.pythonhosted.org/packages/7a/01/4d44aacb9b02561fdbd53948ffc278b78c80e929debba4945809c4cf1295/uv-0.8.20-py3-none-win_arm64.whl", hash = "sha256:23222fd90d843d8c5650f2b3e297dbed4d05a4d28a5e99d017d73aebaa98bea4", size = 19560961, upload-time = "2025-09-22T23:02:21.791Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e6/bb440171dd8a36d0f9874b4c71778f7bbc83e62ccf42c62bd1583c802793/uv-0.8.22-py3-none-linux_armv6l.whl", hash = "sha256:7350c5f82d9c38944e6466933edcf96a90e0cb85eae5c0e53a5bc716d6f62332", size = 20554993, upload-time = "2025-09-23T20:34:26.549Z" }, + { url = "https://files.pythonhosted.org/packages/28/e9/813f7eb9fb9694c4024362782c8933e37887b5195e189f80dc40f2da5958/uv-0.8.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:89944e99b04cc8542cb5931306f1c593f00c9d6f2b652fffc4d84d12b915f911", size = 19565276, upload-time = "2025-09-23T20:34:30.436Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/bf37d86af6e16e45fa2b1a03300784ff3297aa9252a23dfbeaf6e391e72e/uv-0.8.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6706b782ad75662df794e186d16b9ffa4946d57c88f21d0eadfd43425794d1b0", size = 18162303, upload-time = "2025-09-23T20:34:32.761Z" }, + { url = "https://files.pythonhosted.org/packages/e4/eb/289b6a59fff1613958499a886283f52403c5ce4f0a8a550b86fbd70e8e4f/uv-0.8.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:d6a33bd5309f8fb77d9fc249bb17f77a23426e6153e43b03ca1cd6640f0a423d", size = 19982769, upload-time = "2025-09-23T20:34:34.962Z" }, + { url = "https://files.pythonhosted.org/packages/df/ba/2fcc3ce75be62eecf280f3cbe74d186f371a468fad3167b5a34dee2f904e/uv-0.8.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4a982bdd5d239dd6dd2b4219165e209c75af1e1819730454ee46d65b3ccf77a3", size = 20163849, upload-time = "2025-09-23T20:34:37.744Z" }, + { url = "https://files.pythonhosted.org/packages/f4/4d/4fc9a508c2c497a80c41710c96f1782a29edecffcac742f3843af061ba8f/uv-0.8.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58b6fb191a04b922dc3c8fea6660f58545a651843d7d0efa9ae69164fca9e05d", size = 21130147, upload-time = "2025-09-23T20:34:40.414Z" }, + { url = "https://files.pythonhosted.org/packages/71/79/6bcb3c3c3b7c9cb1a162a76dca2b166752e4ba39ec90e802b252f0a54039/uv-0.8.22-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8ea724ae9f15c0cb4964e9e2e1b21df65c56ae02a54dc1d8a6ea44a52d819268", size = 22561974, upload-time = "2025-09-23T20:34:42.843Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/89bb29d82ff7e5ab1b5e862d9bdc12b1d3a4d5201cf558432487e29cc448/uv-0.8.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7378127cbd6ebce8ba6d9bdb88aa8ea995b579824abb5ec381c63b3a123a43be", size = 22183189, upload-time = "2025-09-23T20:34:45.57Z" }, + { url = "https://files.pythonhosted.org/packages/95/b0/354c7d7d11fff2ee97bb208f0fec6b09ae885c0d591b6eff2d7b84cc6695/uv-0.8.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7e761ca7df8a0059b3fae6bc2c1db24583fa00b016e35bd22a5599d7084471a7", size = 21492888, upload-time = "2025-09-23T20:34:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a9/a83cee9b8cf63e57ce64ba27c77777cc66410e144fd178368f55af1fa18d/uv-0.8.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8efec4ef5acddc35f0867998c44e0b15fc4dace1e4c26d01443871a2fbb04bf6", size = 21252972, upload-time = "2025-09-23T20:34:50.862Z" }, + { url = "https://files.pythonhosted.org/packages/0f/0c/71d5d5d3fca7aa788d63297a06ca26d3585270342277b52312bb693b100c/uv-0.8.22-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9eb3b4abfa25e07d7e1bb4c9bb8dbbdd51878356a37c3c4a2ece3d68d4286f28", size = 20115520, upload-time = "2025-09-23T20:34:53.165Z" }, + { url = "https://files.pythonhosted.org/packages/da/90/57fae2798be1e71692872b8304e2e2c345eacbe2070bdcbba6d5a7675fa1/uv-0.8.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b1fdffc2e71892ce648b66317e478fe8884d0007e20cfa582fff3dcea588a450", size = 21168787, upload-time = "2025-09-23T20:34:55.638Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f6/23c8d8fdd1084603795f6344eee8e763ba06f891e863397fe5b7b532cb58/uv-0.8.22-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:f6ded9bacb31441d788afca397b8b884ebc2e70f903bea0a38806194be4b249c", size = 20170112, upload-time = "2025-09-23T20:34:58.008Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/801d517964a7200014897522ae067bf7111fc2e138b38d13d9df9544bf06/uv-0.8.22-py3-none-musllinux_1_1_i686.whl", hash = "sha256:aefa0cb27a86d2145ca9290a1e99c16a17ea26a4f14a89fb7336bc19388427cc", size = 20537608, upload-time = "2025-09-23T20:35:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/20/8a/1bd4159089f8df0128e4ceb7f4c31c23a451984a5b49c13489c70e721335/uv-0.8.22-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:9757f0b0c7d296f1e354db442ed0ce39721c06d11635ce4ee6638c5e809a9cb4", size = 21471224, upload-time = "2025-09-23T20:35:03.718Z" }, + { url = "https://files.pythonhosted.org/packages/86/ba/262d16059e3b0837728e8aa3590fc2c7bc23e0cefec81d6903b4b6af080a/uv-0.8.22-py3-none-win32.whl", hash = "sha256:36c7aecdb0044caf15ace00da00af172759c49c832f0017b7433d80f46552cd3", size = 19350586, upload-time = "2025-09-23T20:35:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/38/82/94f08992eeb193dc3d5baac437d1867cd37f040f34c7b1a4b1bde2bc4b4b/uv-0.8.22-py3-none-win_amd64.whl", hash = "sha256:cda349c9ea53644d8d9ceae30db71616b733eb5330375ab4259765aef494b74e", size = 21355960, upload-time = "2025-09-23T20:35:09.472Z" }, + { url = "https://files.pythonhosted.org/packages/f9/00/2c7a93bbe93b74dc0496a8e875bac11027cb30c29636c106c6e49038b95f/uv-0.8.22-py3-none-win_arm64.whl", hash = "sha256:2a436b941b6e79fe1e1065b705a5689d72210f4367cbe885e19910cbcde2e4a1", size = 19778983, upload-time = "2025-09-23T20:35:12.188Z" }, ] [[package]]