diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md b/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md index 502b342856c7..419e43b050ea 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md @@ -24,6 +24,8 @@ ([#42360] https://github.com/Azure/azure-sdk-for-python/pull/42360) - Configuration manager/worker fetch via OneSettings part 2 - Concurrency and refactoring of _ConfigurationManager ([#42508] https://github.com/Azure/azure-sdk-for-python/pull/42508) +- Refactoring of statsbeat to use `StatsbeatManager` + ([#42716] https://github.com/Azure/azure-sdk-for-python/pull/42716) ## 1.0.0b41 (2025-07-31) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_constants.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_constants.py index 67b50ddc5d9b..ddb192ac3303 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_constants.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_constants.py @@ -90,14 +90,15 @@ # Statsbeat # (OpenTelemetry metric name, Statsbeat metric name) +# Note: OpenTelemetry SDK normalizes metric names to lowercase, so first element should be lowercase _ATTACH_METRIC_NAME = ("attach", "Attach") _FEATURE_METRIC_NAME = ("feature", "Feature") -_REQ_EXCEPTION_NAME = ("statsbeat_exception_count", "Exception_Count") -_REQ_DURATION_NAME = ("statsbeat_duration", "Request_Duration") -_REQ_FAILURE_NAME = ("statsbeat_failure_count", "Request_Failure_Count") -_REQ_RETRY_NAME = ("statsbeat_retry_count", "Retry_Count") -_REQ_SUCCESS_NAME = ("statsbeat_success_count", "Request_Success_Count") -_REQ_THROTTLE_NAME = ("statsbeat_throttle_count", "Throttle_Count") +_REQ_EXCEPTION_NAME = ("exception_count", "Exception_Count") +_REQ_DURATION_NAME = ("request_duration", "Request_Duration") +_REQ_FAILURE_NAME = ("request_failure_count", "Request_Failure_Count") +_REQ_RETRY_NAME = ("retry_count", "Retry_Count") +_REQ_SUCCESS_NAME = ("request_success_count", "Request_Success_Count") +_REQ_THROTTLE_NAME = ("throttle_count", "Throttle_Count") _STATSBEAT_METRIC_NAME_MAPPINGS = dict( [ @@ -117,8 +118,8 @@ # pylint: disable=line-too-long _DEFAULT_NON_EU_STATS_CONNECTION_STRING = "InstrumentationKey=c4a29126-a7cb-47e5-b348-11414998b11e;IngestionEndpoint=https://westus-0.in.applicationinsights.azure.com/" _DEFAULT_EU_STATS_CONNECTION_STRING = "InstrumentationKey=7dc56bab-3c0c-4e9f-9ebb-d1acadee8d0f;IngestionEndpoint=https://westeurope-5.in.applicationinsights.azure.com/" -_DEFAULT_STATS_SHORT_EXPORT_INTERVAL = 900 # 15 minutes -_DEFAULT_STATS_LONG_EXPORT_INTERVAL = 86400 # 24 hours +_DEFAULT_STATS_SHORT_EXPORT_INTERVAL = 15 * 60 # 15 minutes in s +_DEFAULT_STATS_LONG_EXPORT_INTERVAL = 24 * 60 * 60 # 24 hours in s _EU_ENDPOINTS = [ "westeurope", "northeurope", diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_generated/models/_models_py3.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_generated/models/_models_py3.py index 9741154cfb3c..4f7973de2a83 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_generated/models/_models_py3.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_generated/models/_models_py3.py @@ -1320,7 +1320,7 @@ class TrackResponse(msrest.serialization.Model): "errors": {"key": "errors", "type": "[TelemetryErrorDetails]"}, } - def __init__( + def __init__( # type: ignore self, *, items_received: Optional[int] = None, diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_storage.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_storage.py index 025b64718cb7..7dad7d58ca57 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_storage.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_storage.py @@ -8,7 +8,7 @@ import random import subprocess import errno -from typing import Union +from typing import Union, Optional, Any, Generator, Tuple, List, Type from enum import Enum from azure.monitor.opentelemetry.exporter._utils import PeriodicTask @@ -26,15 +26,15 @@ os.environ.get("SYSTEMDRIVE", "C:"), r"\Windows\System32\icacls.exe" ) -def _fmt(timestamp): +def _fmt(timestamp: datetime.datetime) -> str: return timestamp.strftime("%Y-%m-%dT%H%M%S.%f") -def _now(): +def _now() -> datetime.datetime: return datetime.datetime.now(tz=datetime.timezone.utc) -def _seconds(seconds): +def _seconds(seconds: int) -> datetime.timedelta: return datetime.timedelta(seconds=seconds) class StorageExportResult(Enum): @@ -45,16 +45,16 @@ class StorageExportResult(Enum): # pylint: disable=broad-except class LocalFileBlob: - def __init__(self, fullpath): + def __init__(self, fullpath: str) -> None: self.fullpath = fullpath - def delete(self): + def delete(self) -> None: try: os.remove(self.fullpath) except Exception: pass # keep silent - def get(self): + def get(self) -> Optional[Tuple[Any, ...]]: try: with open(self.fullpath, "r", encoding="utf-8") as file: return tuple(json.loads(line.strip()) for line in file.readlines()) @@ -62,7 +62,7 @@ def get(self): pass # keep silent return None - def put(self, data, lease_period=0) -> Union[StorageExportResult, str]: + def put(self, data: List[Any], lease_period: int = 0) -> Union[StorageExportResult, str]: try: fullpath = self.fullpath + ".tmp" with open(fullpath, "w", encoding="utf-8") as file: @@ -80,7 +80,7 @@ def put(self, data, lease_period=0) -> Union[StorageExportResult, str]: except Exception as ex: return str(ex) - def lease(self, period): + def lease(self, period: int) -> Optional['LocalFileBlob']: timestamp = _now() + _seconds(period) fullpath = self.fullpath if fullpath.endswith(".lock"): @@ -98,14 +98,14 @@ def lease(self, period): class LocalFileStorage: def __init__( self, - path, - max_size=50 * 1024 * 1024, # 50MiB - maintenance_period=60, # 1 minute - retention_period=48 * 60 * 60, # 48 hours - write_timeout=60, # 1 minute, - name=None, - lease_period=60, # 1 minute - ): + path: str, + max_size: int = 50 * 1024 * 1024, # 50MiB + maintenance_period: int = 60, # 1 minute + retention_period: int = 48 * 60 * 60, # 48 hours + write_timeout: int = 60, # 1 minute, + name: Optional[str] = None, + lease_period: int = 60, # 1 minute + ) -> None: self._path = os.path.abspath(path) self._max_size = max_size self._retention_period = retention_period @@ -124,19 +124,24 @@ def __init__( else: logger.error("Could not set secure permissions on storage folder, local storage is disabled.") - def close(self): + def close(self) -> None: if self._enabled: self._maintenance_task.cancel() self._maintenance_task.join() - def __enter__(self): + def __enter__(self) -> 'LocalFileStorage': return self # pylint: disable=redefined-builtin - def __exit__(self, type, value, traceback): + def __exit__( + self, + exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[Any] + ) -> None: self.close() - def _maintenance_routine(self): + def _maintenance_routine(self) -> None: try: # pylint: disable=unused-variable for blob in self.gets(): @@ -145,7 +150,7 @@ def _maintenance_routine(self): pass # keep silent # pylint: disable=too-many-nested-blocks - def gets(self): + def gets(self) -> Generator[LocalFileBlob, None, None]: if self._enabled: now = _now() lease_deadline = _fmt(now) @@ -184,7 +189,7 @@ def gets(self): else: pass - def get(self): + def get(self) -> Optional['LocalFileBlob']: if not self._enabled: return None cursor = self.gets() @@ -194,7 +199,7 @@ def get(self): pass return None - def put(self, data, lease_period=None) -> Union[StorageExportResult, str]: + def put(self, data: List[Any], lease_period: Optional[int] = None) -> Union[StorageExportResult, str]: try: if not self._enabled: if get_local_storage_setup_state_readonly(): @@ -221,7 +226,7 @@ def put(self, data, lease_period=None) -> Union[StorageExportResult, str]: return str(ex) - def _check_and_set_folder_permissions(self): + def _check_and_set_folder_permissions(self) -> bool: """ Validate and set folder permissions where the telemetry data will be stored. :return: True if folder was created and permissions set successfully, False otherwise. @@ -266,7 +271,7 @@ def _check_and_set_folder_permissions(self): set_local_storage_setup_state_exception(str(ex)) return False - def _check_storage_size(self): + def _check_storage_size(self) -> bool: size = 0 # pylint: disable=unused-variable for dirpath, dirnames, filenames in os.walk(self._path): @@ -295,7 +300,7 @@ def _check_storage_size(self): return False return True - def _get_current_user(self): + def _get_current_user(self) -> str: user = "" domain = os.environ.get("USERDOMAIN") username = os.environ.get("USERNAME") diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_utils.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_utils.py index 6f9d35c51c01..96688fa03872 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_utils.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_utils.py @@ -380,10 +380,13 @@ def _get_scope(aad_audience=None): class Singleton(type): _instance = None + _lock = threading.Lock() - def __call__(cls, *args, **kwargs): + def __call__(cls, *args: Any, **kwargs: Any): if not cls._instance: - cls._instance = super(Singleton, cls).__call__(*args, **kwargs) + with cls._lock: + if not cls._instance: + cls._instance = super(Singleton, cls).__call__(*args, **kwargs) return cls._instance def _get_telemetry_type(item: TelemetryItem): diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/_base.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/_base.py index 0c6516a9b28a..2ed782cde8c9 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/_base.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/_base.py @@ -51,6 +51,7 @@ from azure.monitor.opentelemetry.exporter.statsbeat._state import ( get_statsbeat_initial_success, get_statsbeat_shutdown, + get_customer_sdkstats_shutdown, increment_and_check_statsbeat_failure_count, is_statsbeat_enabled, set_statsbeat_initial_success, @@ -99,9 +100,11 @@ def __init__(self, **kwargs: Any) -> None: # self._configuration_manager = _ConfigurationManager() self._api_version = kwargs.get("api_version") or _SERVICE_API_LATEST + # We do not need to use entra Id if this is a sdkStats exporter if self._is_stats_exporter(): self._credential = None else: + # We use the credential on a regular exporter or customer sdkStats exporter self._credential = _get_authentication_credential(**kwargs) self._consecutive_redirects = 0 # To prevent circular redirects self._disable_offline_storage = kwargs.get("disable_offline_storage", False) @@ -157,8 +160,8 @@ def __init__(self, **kwargs: Any) -> None: ) self.storage = None if not self._disable_offline_storage: - self.storage = LocalFileStorage( - path=self._storage_directory, + self.storage = LocalFileStorage( # pyright: ignore + path=self._storage_directory, # type: ignore max_size=self._storage_max_size, maintenance_period=self._storage_maintenance_period, retention_period=self._storage_retention_period, @@ -170,10 +173,12 @@ def __init__(self, **kwargs: Any) -> None: # statsbeat initialization if self._should_collect_stats(): - # Import here to avoid circular dependencies - from azure.monitor.opentelemetry.exporter.statsbeat._statsbeat import collect_statsbeat_metrics - - collect_statsbeat_metrics(self) + try: + # Import here to avoid circular dependencies + from azure.monitor.opentelemetry.exporter.statsbeat._statsbeat import collect_statsbeat_metrics + collect_statsbeat_metrics(self) + except Exception as e: # pylint: disable=broad-except + logger.warning("Failed to initialize statsbeat metrics: %s", e) # Initialize customer sdkstats if enabled self._customer_sdkstats_metrics = None @@ -453,21 +458,20 @@ def _should_collect_stats(self): is_statsbeat_enabled() and not get_statsbeat_shutdown() and not self._is_stats_exporter() + and not self._is_customer_sdkstats_exporter() and not self._instrumentation_collection ) # check to see whether its the case of customer sdkstats collection def _should_collect_customer_sdkstats(self): - # Import here to avoid circular dependencies - from azure.monitor.opentelemetry.exporter.statsbeat._state import get_customer_sdkstats_shutdown - env_value = os.environ.get("APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW", "") is_customer_sdkstats_enabled = env_value.lower() == "true" - # Don't collect customer sdkstats for instrumentation collection or customer sdkstats exporter + # Don't collect customer sdkstats for instrumentation collection, sdkstats exporter or customer sdkstats exporter return ( is_customer_sdkstats_enabled and not get_customer_sdkstats_shutdown() + and not self._is_stats_exporter() and not self._is_customer_sdkstats_exporter() and not self._instrumentation_collection ) @@ -477,7 +481,7 @@ def _is_statsbeat_initializing_state(self): return self._is_stats_exporter() and not get_statsbeat_shutdown() and not get_statsbeat_initial_success() def _is_stats_exporter(self): - return self.__class__.__name__ == "_StatsBeatExporter" + return getattr(self, "_is_sdkstats", False) def _is_customer_sdkstats_exporter(self): return getattr(self, '_is_customer_sdkstats', False) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/metrics/_exporter.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/metrics/_exporter.py index 5ecf2397f830..2b212d514626 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/metrics/_exporter.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/metrics/_exporter.py @@ -40,6 +40,7 @@ _APPLICATIONINSIGHTS_METRIC_NAMESPACE_OPT_IN, _AUTOCOLLECTED_INSTRUMENT_NAMES, _METRIC_ENVELOPE_NAME, + _STATSBEAT_METRIC_NAME_MAPPINGS, ) from azure.monitor.opentelemetry.exporter import _utils from azure.monitor.opentelemetry.exporter._generated.models import ( @@ -75,13 +76,15 @@ class AzureMonitorMetricExporter(BaseExporter, MetricExporter): """Azure Monitor Metric exporter for OpenTelemetry.""" def __init__(self, **kwargs: Any) -> None: + self._is_sdkstats = kwargs.get("is_sdkstats", False) + self._is_customer_sdkstats = kwargs.get("is_customer_sdkstats", False) + self._metrics_to_log_analytics = self._determine_metrics_to_log_analytics() BaseExporter.__init__(self, **kwargs) MetricExporter.__init__( self, preferred_temporality=APPLICATION_INSIGHTS_METRIC_TEMPORALITIES, # type: ignore preferred_aggregation=kwargs.get("preferred_aggregation"), # type: ignore ) - self._metrics_to_log_analytics = self._determine_metrics_to_log_analytics() # pylint: disable=R1702 def export( @@ -157,7 +160,13 @@ def _point_to_envelope( # When Metrics to Log Analytics is disabled, only send Standard metrics and _OTELRESOURCE_ if not self._metrics_to_log_analytics and name not in _AUTOCOLLECTED_INSTRUMENT_NAMES: return None - envelope = _convert_point_to_envelope(point, name, resource, scope) + + # Apply statsbeat metric name mapping if this is a statsbeat exporter + final_metric_name = name + if self._is_sdkstats and name in _STATSBEAT_METRIC_NAME_MAPPINGS: + final_metric_name = _STATSBEAT_METRIC_NAME_MAPPINGS[name] + + envelope = _convert_point_to_envelope(point, final_metric_name, resource, scope) if name in _AUTOCOLLECTED_INSTRUMENT_NAMES: envelope = _handle_std_metric_envelope(envelope, name, point.attributes) # type: ignore if envelope is not None: @@ -182,8 +191,11 @@ def _determine_metrics_to_log_analytics(self) -> bool: :return: False if metrics should not be sent to Log Analytics, True otherwise. :rtype: bool """ + # If sdkStats exporter, always send to LA + if self._is_sdkstats: + return True # Disabling metrics to Log Analytics via env var is currently only specified for AKS Attach scenarios. - if not _utils._is_on_aks() or not _utils._is_attach_enabled() or self._is_stats_exporter(): + if not _utils._is_on_aks() or not _utils._is_attach_enabled(): return True env_var = os.environ.get(_APPLICATIONINSIGHTS_METRICS_TO_LOGANALYTICS_ENABLED) if not env_var: diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/__init__.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/__init__.py index e69de29bb2d1..d95f5d59ea09 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/__init__.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/__init__.py @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +""" +Statsbeat metrics collection module. + +This module provides a singleton-based, thread-safe manager for collecting +and reporting statsbeat metrics. +""" + +from azure.monitor.opentelemetry.exporter.statsbeat._statsbeat import ( + collect_statsbeat_metrics, + shutdown_statsbeat_metrics, +) +from azure.monitor.opentelemetry.exporter.statsbeat._manager import ( + StatsbeatConfig, + StatsbeatManager, +) + +__all__ = [ + 'StatsbeatConfig', + 'StatsbeatManager', + 'collect_statsbeat_metrics', + 'shutdown_statsbeat_metrics', +] diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_customer_sdkstats.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_customer_sdkstats.py index cdcd2260ea8d..7bfffaa71328 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_customer_sdkstats.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_customer_sdkstats.py @@ -25,7 +25,7 @@ _CUSTOMER_SDKSTATS_LANGUAGE, ) -from azure.monitor.opentelemetry.exporter.export.metrics._exporter import AzureMonitorMetricExporter + from azure.monitor.opentelemetry.exporter._utils import ( Singleton, get_compute_type, @@ -35,7 +35,6 @@ categorize_status_code, _get_customer_sdkstats_export_interval, ) -from azure.monitor.opentelemetry.exporter import VERSION from azure.monitor.opentelemetry.exporter.statsbeat._state import ( _CUSTOMER_SDKSTATS_STATE, @@ -44,28 +43,30 @@ class _CustomerSdkStatsTelemetryCounters: def __init__(self): - self.total_item_success_count: Dict[str, Any] = {} - self.total_item_drop_count: Dict[str, Dict[DropCodeType, Dict[str, int]]] = {} - self.total_item_retry_count: Dict[str, Dict[RetryCodeType, Dict[str, int]]] = {} + self.total_item_success_count: Dict[str, Any] = {} # type: ignore + self.total_item_drop_count: Dict[str, Dict[DropCodeType, Dict[str, int]]] = {} # type: ignore + self.total_item_retry_count: Dict[str, Dict[RetryCodeType, Dict[str, int]]] = {} # type: ignore + class CustomerSdkStatsMetrics(metaclass=Singleton): # pylint: disable=too-many-instance-attributes def __init__(self, connection_string): self._counters = _CustomerSdkStatsTelemetryCounters() self._language = _CUSTOMER_SDKSTATS_LANGUAGE - self._is_enabled = os.environ.get(_APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW, "").lower() in ("true") + self._is_enabled = os.environ.get(_APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW, "").lower() == "true" if not self._is_enabled: return - exporter_config = { - "connection_string": connection_string, - "instrumentation_collection": True, # Prevent circular dependency - } + # Use delayed import to avoid circular import + from azure.monitor.opentelemetry.exporter.export.metrics._exporter import AzureMonitorMetricExporter + from azure.monitor.opentelemetry.exporter import VERSION - self._customer_sdkstats_exporter = AzureMonitorMetricExporter(**exporter_config) - self._customer_sdkstats_exporter._is_customer_sdkstats = True + self._customer_sdkstats_exporter = AzureMonitorMetricExporter( + connection_string=connection_string, + is_customer_sdkstats=True, + ) metric_reader_options = { "exporter": self._customer_sdkstats_exporter, - "export_interval_millis": _get_customer_sdkstats_export_interval() + "export_interval_millis": _get_customer_sdkstats_export_interval() * 1000 # Default 15m } self._customer_sdkstats_metric_reader = PeriodicExportingMetricReader(**metric_reader_options) self._customer_sdkstats_meter_provider = MeterProvider( diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_exporter.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_exporter.py deleted file mode 100644 index c476d3ac6647..000000000000 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_exporter.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -from typing import Optional -from opentelemetry.sdk.metrics.export import DataPointT -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.util.instrumentation import InstrumentationScope - -from azure.monitor.opentelemetry.exporter._generated.models import TelemetryItem -from azure.monitor.opentelemetry.exporter import AzureMonitorMetricExporter -from azure.monitor.opentelemetry.exporter._constants import _STATSBEAT_METRIC_NAME_MAPPINGS - - -class _StatsBeatExporter(AzureMonitorMetricExporter): - - def _point_to_envelope( - self, - point: DataPointT, - name: str, - resource: Optional[Resource] = None, - scope: Optional[InstrumentationScope] = None, - ) -> Optional[TelemetryItem]: - # map statsbeat name from OpenTelemetry name - name = _STATSBEAT_METRIC_NAME_MAPPINGS[name] - return super()._point_to_envelope( - point, - name, - resource, - None, - ) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_manager.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_manager.py new file mode 100644 index 000000000000..0708f249662a --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_manager.py @@ -0,0 +1,223 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import logging +import threading +from typing import Optional, Any + +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.resources import Resource + +from azure.monitor.opentelemetry.exporter.statsbeat._statsbeat_metrics import _StatsbeatMetrics +from azure.monitor.opentelemetry.exporter.statsbeat._state import ( + is_statsbeat_enabled, + set_statsbeat_shutdown, # Add this import +) +from azure.monitor.opentelemetry.exporter.statsbeat._utils import ( + _get_stats_connection_string, + _get_stats_long_export_interval, + _get_stats_short_export_interval, +) +from azure.monitor.opentelemetry.exporter._utils import Singleton + +logger = logging.getLogger(__name__) + + +class StatsbeatConfig: + """Configuration class for Statsbeat metrics collection.""" + + def __init__(self, + endpoint: str, + instrumentation_key: str, + disable_offline_storage: bool = False, + credential: Optional[Any] = None, + distro_version: Optional[str] = None) -> None: + self.endpoint = endpoint + self.instrumentation_key = instrumentation_key + self.connection_string: str = _get_stats_connection_string(endpoint) + self.distro_version = distro_version + # features + self.disable_offline_storage = disable_offline_storage + self.credential = credential + + @classmethod + def from_exporter(cls, exporter: Any) -> 'StatsbeatConfig': + # Create configuration from an exporter instance + return cls( + endpoint=exporter._endpoint, # pylint: disable=protected-access + instrumentation_key=exporter._instrumentation_key, # pylint: disable=protected-access + disable_offline_storage=exporter._disable_offline_storage, # pylint: disable=protected-access + credential=exporter._credential, # pylint: disable=protected-access + distro_version=exporter._distro_version, # pylint: disable=protected-access + ) + + def __eq__(self, other: object) -> bool: + # Compare two configurations for equality based on what can be changed via control plane. + if not isinstance(other, StatsbeatConfig): + return False + return ( + self.connection_string == other.connection_string and + self.disable_offline_storage == other.disable_offline_storage + ) + + def __hash__(self) -> int: + # Hash based on connection string and offline storage setting. + return hash((self.connection_string, self.disable_offline_storage)) + + +class StatsbeatManager(metaclass=Singleton): + """Thread-safe singleton manager for Statsbeat metrics collection with dynamic reconfiguration support.""" + + def __init__(self) -> None: + # Initialize instance attributes. Called only once due to Singleton metaclass. + self._lock = threading.Lock() + self._initialized: bool = False # type: ignore + self._metrics: Optional[_StatsbeatMetrics] = None # type: ignore + self._meter_provider: Optional[MeterProvider] = None # type: ignore + self._config: Optional[StatsbeatConfig] = None # type: ignore + + def initialize(self, config: 'StatsbeatConfig') -> bool: # pyright: ignore + # Initialize statsbeat collection with thread safety. + if not is_statsbeat_enabled(): + return False + + with self._lock: + if self._initialized: + # If already initialized with the same config, return True + if self._config and self._config == config: + return True + # If config is different, reconfigure + return self._reconfigure(config) + + return self._do_initialize(config) + + def _do_initialize(self, config: StatsbeatConfig) -> bool: + # Internal initialization method. + try: + # Create statsbeat exporter + # Use delayed import to avoid circular import + from azure.monitor.opentelemetry.exporter.export.metrics._exporter import AzureMonitorMetricExporter + + statsbeat_exporter = AzureMonitorMetricExporter( + connection_string=config.connection_string, + disable_offline_storage=config.disable_offline_storage, + is_sdkstats=True, + ) + + # Create metric reader + reader = PeriodicExportingMetricReader( + statsbeat_exporter, + export_interval_millis=_get_stats_short_export_interval() * 1000, # 15m by default + ) + + # Create meter provider + self._meter_provider = MeterProvider( + metric_readers=[reader], + resource=Resource.get_empty(), + ) + + # long_interval_threshold represents how many collects for short interval + # should have passed before a long interval collect + long_interval_threshold = ( + _get_stats_long_export_interval() // _get_stats_short_export_interval() + ) + + # Create statsbeat metrics + self._metrics = _StatsbeatMetrics( + self._meter_provider, + config.instrumentation_key, + config.endpoint, + config.disable_offline_storage, + long_interval_threshold, + config.credential is not None, + config.distro_version, + ) + + # Force initial flush and initialize non-initial metrics + self._meter_provider.force_flush() + self._metrics.init_non_initial_metrics() + + self._config = config + self._initialized = True + return True + + except Exception as e: # pylint: disable=broad-except + # Log the error for debugging + logger.warning("Failed to initialize statsbeat: %s", e) + # Clean up on failure + self._cleanup() + return False + + def _cleanup(self) -> None: + # Clean up resources. + if self._meter_provider: + try: + self._meter_provider.shutdown() + except Exception: # pylint: disable=broad-except + pass + self._meter_provider = None + self._metrics = None + self._config = None + self._initialized = False + + def shutdown(self) -> bool: + # Shutdown statsbeat collection with thread safety. + with self._lock: + if not self._initialized: + return False + + shutdown_success = False + try: + if self._meter_provider is not None: + self._meter_provider.shutdown() + shutdown_success = True + except Exception: # pylint: disable=broad-except + pass + finally: + self._cleanup() + + if shutdown_success: + set_statsbeat_shutdown(True) # Use the proper setter function + + return shutdown_success + + def reconfigure(self, new_config: 'StatsbeatConfig') -> bool: # pyright: ignore + # Reconfigure statsbeat with new configuration. + if not is_statsbeat_enabled(): + return False + + with self._lock: + if not self._initialized: + # If not initialized, just initialize with new config + return self._do_initialize(new_config) + + # If same config, no need to reconfigure + if self._config and self._config == new_config: + return True + + return self._reconfigure(new_config) + + def _reconfigure(self, new_config: StatsbeatConfig) -> bool: + # Internal reconfiguration method. + # Shutdown current instance with timeout + if self._meter_provider: + try: + # Force flush before shutdown to ensure data is sent + self._meter_provider.force_flush(timeout_millis=5000) + self._meter_provider.shutdown(timeout_millis=5000) + except Exception: # pylint: disable=broad-except + pass + + # Reset state but keep initialized=True + self._meter_provider = None + self._metrics = None + self._config = None + + # Initialize with new config + success = self._do_initialize(new_config) + + if not success: + # If reinitialization failed, mark as not initialized + self._initialized = False + + return success diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_state.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_state.py index fb3ed6fb209f..4f1b536432d9 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_state.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_state.py @@ -96,3 +96,7 @@ def get_local_storage_setup_state_exception(): def set_local_storage_setup_state_exception(value): with _LOCAL_STORAGE_SETUP_STATE_LOCK: _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = value + +def set_statsbeat_shutdown(shutdown: bool): + with _STATSBEAT_STATE_LOCK: + _STATSBEAT_STATE["SHUTDOWN"] = shutdown diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_statsbeat.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_statsbeat.py index e6dcee0c3aca..e9ab32efea60 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_statsbeat.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_statsbeat.py @@ -1,77 +1,15 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import threading - -from opentelemetry.sdk.metrics import MeterProvider -from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader -from opentelemetry.sdk.resources import Resource - -from azure.monitor.opentelemetry.exporter.statsbeat._exporter import _StatsBeatExporter -from azure.monitor.opentelemetry.exporter.statsbeat._statsbeat_metrics import _StatsbeatMetrics -from azure.monitor.opentelemetry.exporter.statsbeat._state import ( - _STATSBEAT_STATE, - _STATSBEAT_STATE_LOCK, -) -from azure.monitor.opentelemetry.exporter.statsbeat._utils import ( - _get_stats_connection_string, - _get_stats_long_export_interval, - _get_stats_short_export_interval, +from azure.monitor.opentelemetry.exporter.statsbeat._manager import ( + StatsbeatConfig, + StatsbeatManager, ) -_STATSBEAT_METRICS = None -_STATSBEAT_LOCK = threading.Lock() - - -# pylint: disable=global-statement -# pylint: disable=protected-access -def collect_statsbeat_metrics(exporter) -> None: - global _STATSBEAT_METRICS - # Only start statsbeat if did not exist before - if _STATSBEAT_METRICS is None: - with _STATSBEAT_LOCK: - statsbeat_exporter = _StatsBeatExporter( - connection_string=_get_stats_connection_string(exporter._endpoint), - disable_offline_storage=exporter._disable_offline_storage, - ) - reader = PeriodicExportingMetricReader( - statsbeat_exporter, - export_interval_millis=_get_stats_short_export_interval() * 1000, # 15m by default - ) - mp = MeterProvider( - metric_readers=[reader], - resource=Resource.get_empty(), - ) - # long_interval_threshold represents how many collects for short interval - # should have passed before a long interval collect - long_interval_threshold = _get_stats_long_export_interval() // _get_stats_short_export_interval() - _STATSBEAT_METRICS = _StatsbeatMetrics( - mp, - exporter._instrumentation_key, - exporter._endpoint, - exporter._disable_offline_storage, - long_interval_threshold, - exporter._credential is not None, - exporter._distro_version, - ) - # Export some initial stats on program start - mp.force_flush() - # initialize non-initial stats - _STATSBEAT_METRICS.init_non_initial_metrics() +def collect_statsbeat_metrics(exporter) -> None: # pyright: ignore + config = StatsbeatConfig.from_exporter(exporter) + StatsbeatManager().initialize(config) -def shutdown_statsbeat_metrics() -> None: - global _STATSBEAT_METRICS - shutdown_success = False - if _STATSBEAT_METRICS is not None: - with _STATSBEAT_LOCK: - try: - if _STATSBEAT_METRICS._meter_provider is not None: - _STATSBEAT_METRICS._meter_provider.shutdown() - _STATSBEAT_METRICS = None - shutdown_success = True - except: # pylint: disable=bare-except - pass - if shutdown_success: - with _STATSBEAT_STATE_LOCK: - _STATSBEAT_STATE["SHUTDOWN"] = True +def shutdown_statsbeat_metrics() -> bool: + return StatsbeatManager().shutdown() diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_statsbeat_metrics.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_statsbeat_metrics.py index f7e3e94e7815..3c411ee58151 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_statsbeat_metrics.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_statsbeat_metrics.py @@ -7,14 +7,13 @@ import re import sys import threading -from typing import Any, Dict, Iterable, List +from typing import Any, Dict, Optional, Iterable, List import requests # pylint: disable=networking-import-outside-azure-core-transport from opentelemetry.metrics import CallbackOptions, Observation from opentelemetry.sdk.metrics import MeterProvider -from azure.monitor.opentelemetry.exporter import VERSION from azure.monitor.opentelemetry.exporter._constants import ( _ATTACH_METRIC_NAME, _FEATURE_METRIC_NAME, @@ -38,6 +37,12 @@ ) from azure.monitor.opentelemetry.exporter import _utils +# Use a function to get VERSION lazily +def _get_version() -> str: + # Get VERSION using delayed import to avoid circular import. + from azure.monitor.opentelemetry.exporter import VERSION + return VERSION + # cSpell:disable _AIMS_URI = "http://169.254.169.254/metadata/instance/compute" @@ -88,7 +93,7 @@ class _StatsbeatMetrics: "runtimeVersion": platform.python_version(), "os": platform.system(), "language": "python", - "version": VERSION, + "version": None, # Will be set lazily } _NETWORK_ATTRIBUTES: Dict[str, Any] = { @@ -114,8 +119,12 @@ def __init__( disable_offline_storage: bool, long_interval_threshold: int, has_credential: bool, - distro_version: str = "", + distro_version: Optional[str] = "", ) -> None: + # Set the version if not already set using delayed import + if _StatsbeatMetrics._COMMON_ATTRIBUTES["version"] is None: + _StatsbeatMetrics._COMMON_ATTRIBUTES["version"] = _get_version() + self._ikey = instrumentation_key self._feature = _StatsbeatFeature.NONE if not disable_offline_storage: @@ -138,9 +147,12 @@ def __init__( _FEATURE_METRIC_NAME[0]: sys.maxsize, } self._long_interval_lock = threading.Lock() + + # Initialize common attributes and set values _StatsbeatMetrics._COMMON_ATTRIBUTES["cikey"] = instrumentation_key if _utils._is_attach_enabled(): _StatsbeatMetrics._COMMON_ATTRIBUTES["attach"] = _AttachTypes.INTEGRATED + _StatsbeatMetrics._NETWORK_ATTRIBUTES["host"] = _shorten_host(endpoint) _StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] = self._feature _StatsbeatMetrics._INSTRUMENTATION_ATTRIBUTES["feature"] = _utils.get_instrumentations() @@ -191,7 +203,7 @@ def _get_attach_metric(self, options: CallbackOptions) -> Iterable[Observation]: if _AKS_ARM_NAMESPACE_ID in os.environ: rpId = os.environ.get(_AKS_ARM_NAMESPACE_ID, "") else: - rpId = os.environ.get(_KUBERNETES_SERVICE_HOST , "") + rpId = os.environ.get(_KUBERNETES_SERVICE_HOST, "") elif self._vm_retry and self._get_azure_compute_metadata(): # VM rp = _RP_Names.VM.value @@ -265,19 +277,19 @@ def _get_feature_metric(self, options: CallbackOptions) -> Iterable[Observation] return observations - def _meets_long_interval_threshold(self, name) -> bool: + def _meets_long_interval_threshold(self, name: str) -> bool: with self._long_interval_lock: - # if long interval theshold not met, it is not time to export + # if long interval threshold not met, it is not time to export # statsbeat metrics that are long intervals count = self._long_interval_count_map.get(name, sys.maxsize) if count < self._long_interval_threshold: return False - # reset the count if long interval theshold is met + # reset the count if long interval threshold is met self._long_interval_count_map[name] = 0 return True # pylint: disable=W0201 - def init_non_initial_metrics(self): + def init_non_initial_metrics(self) -> None: # Network metrics - metrics related to request calls to ingestion service self._success_count = self._meter.create_observable_gauge( _REQ_SUCCESS_NAME[0], diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_utils.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_utils.py index 01fcd3c0222b..4db2e130cb9d 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_utils.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_utils.py @@ -12,7 +12,6 @@ ) from azure.monitor.opentelemetry.exporter._utils import _get_telemetry_type from azure.monitor.opentelemetry.exporter._generated.models import TelemetryItem -from azure.monitor.opentelemetry.exporter._storage import StorageExportResult from azure.monitor.opentelemetry.exporter.statsbeat._state import get_local_storage_setup_state_exception @@ -29,9 +28,10 @@ _REQ_SUCCESS_NAME, _APPLICATIONINSIGHTS_SDKSTATS_EXPORT_INTERVAL, ) + from azure.monitor.opentelemetry.exporter.statsbeat._state import ( - _REQUESTS_MAP_LOCK, _REQUESTS_MAP, + _REQUESTS_MAP_LOCK, ) def _get_stats_connection_string(endpoint: str) -> str: @@ -185,6 +185,9 @@ def _track_retry_items(customer_sdkstats_metrics, envelopes: List[TelemetryItem] def _track_dropped_items_from_storage(customer_sdkstats_metrics, result_from_storage_put, envelopes): if customer_sdkstats_metrics: + # Use delayed import to avoid circular import + from azure.monitor.opentelemetry.exporter._storage import StorageExportResult + if result_from_storage_put == StorageExportResult.CLIENT_STORAGE_DISABLED: # Track items that would have been retried but are dropped since client has local storage disabled _track_dropped_items(customer_sdkstats_metrics, envelopes, DropCode.CLIENT_STORAGE_DISABLED) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/metrics/test_metrics.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/metrics/test_metrics.py index e700afa479fd..31ffc81f1008 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/metrics/test_metrics.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/metrics/test_metrics.py @@ -26,7 +26,6 @@ AzureMonitorMetricExporter, _get_metric_export_result, ) -from azure.monitor.opentelemetry.exporter.statsbeat._exporter import _StatsBeatExporter from azure.monitor.opentelemetry.exporter._generated.models import ContextTagKeys from azure.monitor.opentelemetry.exporter._utils import ( azure_monitor_context, @@ -292,7 +291,9 @@ def test_point_to_envelope_aks_amw(self, attach_mock, aks_mock): @mock.patch("azure.monitor.opentelemetry.exporter.export.metrics._exporter._utils._is_on_aks", return_value=True) @mock.patch("azure.monitor.opentelemetry.exporter.export.metrics._exporter._utils._is_attach_enabled", return_value=True) def test_point_to_envelope_statsbeat(self, attach_mock, aks_mock): - exporter = _StatsBeatExporter() + exporter = AzureMonitorMetricExporter( + is_sdkstats=True, + ) point = self._number_data_point envelope = exporter._point_to_envelope(point, "attach") self.assertEqual(len(envelope.data.base_data.properties), 1) @@ -817,7 +818,9 @@ def test_constructor_log_analytics_disabled_env_var(self, attach_mock, aks_mock) @mock.patch("azure.monitor.opentelemetry.exporter.export.metrics._exporter._utils._is_on_aks", return_value=True) @mock.patch("azure.monitor.opentelemetry.exporter.export.metrics._exporter._utils._is_attach_enabled", return_value=True) def test_constructor_log_analytics_statsbeat(self, attach_mock, aks_mock): - exporter = _StatsBeatExporter() + exporter = AzureMonitorMetricExporter( + is_sdkstats=True, + ) self.assertTrue(exporter._metrics_to_log_analytics) @mock.patch.dict("os.environ", { diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_customer_sdkstats.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_customer_sdkstats.py index 85d41090c3c5..7e284dd99a11 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_customer_sdkstats.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_customer_sdkstats.py @@ -124,12 +124,12 @@ def test_customer_sdkstats_not_initialized_when_disabled(self): def test_custom_export_interval_from_env_var(self): """Test that a custom export interval is picked up from the environment variable.""" # Use a non-default value to test - custom_interval = 300 + custom_interval_s = 300 # Mock the environment variable with our custom interval with mock.patch.dict(os.environ, { _APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW: "true", - _APPLICATIONINSIGHTS_SDKSTATS_EXPORT_INTERVAL: str(custom_interval) + _APPLICATIONINSIGHTS_SDKSTATS_EXPORT_INTERVAL: str(custom_interval_s) }): # Get the export interval actual_interval = _get_customer_sdkstats_export_interval() @@ -137,8 +137,8 @@ def test_custom_export_interval_from_env_var(self): # Verify it matches our custom value self.assertEqual( actual_interval, - custom_interval, - f"Expected export interval to be {custom_interval}, got {actual_interval}" + custom_interval_s, + f"Expected export interval to be {custom_interval_s}, got {actual_interval}" ) # Verify the CustomerSdkStatsMetrics instance picks up the custom interval @@ -146,8 +146,8 @@ def test_custom_export_interval_from_env_var(self): metrics = CustomerSdkStatsMetrics(self.mock_options.connection_string) self.assertEqual( metrics._customer_sdkstats_metric_reader._export_interval_millis, - custom_interval, - f"CustomerSdkStatsMetrics should use export interval {custom_interval}, got {metrics._customer_sdkstats_metric_reader._export_interval_millis}" + custom_interval_s * 1000, + f"CustomerSdkStatsMetrics should use export interval {custom_interval_s}, got {metrics._customer_sdkstats_metric_reader._export_interval_millis}" ) def test_default_export_interval_when_env_var_empty(self): @@ -172,7 +172,7 @@ def test_default_export_interval_when_env_var_empty(self): metrics = CustomerSdkStatsMetrics(self.mock_options.connection_string) self.assertEqual( metrics._customer_sdkstats_metric_reader._export_interval_millis, - _DEFAULT_STATS_SHORT_EXPORT_INTERVAL, + _DEFAULT_STATS_SHORT_EXPORT_INTERVAL * 1000, f"CustomerSdkStatsMetrics should use default export interval {_DEFAULT_STATS_SHORT_EXPORT_INTERVAL}, got {metrics._customer_sdkstats_metric_reader._export_interval_millis}" ) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_exporter.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_exporter.py deleted file mode 100644 index 86ace56899c3..000000000000 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_exporter.py +++ /dev/null @@ -1,147 +0,0 @@ -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. -import os -import shutil -import unittest -from unittest import mock -from datetime import datetime - -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.metrics.export import NumberDataPoint - -from azure.core.exceptions import HttpResponseError -from azure.monitor.opentelemetry.exporter.export._base import ExportResult -from azure.monitor.opentelemetry.exporter.statsbeat._exporter import _StatsBeatExporter -from azure.monitor.opentelemetry.exporter.statsbeat._state import _STATSBEAT_STATE -from azure.monitor.opentelemetry.exporter._constants import _STATSBEAT_METRIC_NAME_MAPPINGS -from azure.monitor.opentelemetry.exporter._generated import AzureMonitorClient - -from azure.monitor.opentelemetry.exporter._generated.models import ( - TelemetryErrorDetails, - TelemetryItem, - TrackResponse, -) - - -def throw(exc_type, *args, **kwargs): - def func(*_args, **_kwargs): - raise exc_type(*args, **kwargs) - - return func - - -def clean_folder(folder): - if os.path.isfile(folder): - for filename in os.listdir(folder): - file_path = os.path.join(folder, filename) - try: - if os.path.isfile(file_path) or os.path.islink(file_path): - os.unlink(file_path) - elif os.path.isdir(file_path): - shutil.rmtree(file_path) - except Exception as e: - print("Failed to delete %s. Reason: %s" % (file_path, e)) - - -# pylint: disable=protected-access -class TestStatsbeatExporter(unittest.TestCase): - @classmethod - def setUpClass(cls): - os.environ.pop("APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL", None) - os.environ.pop("APPINSIGHTS_INSTRUMENTATIONKEY", None) - os.environ["APPINSIGHTS_INSTRUMENTATIONKEY"] = "1234abcd-5678-4efa-8abc-1234567890ab" - os.environ["APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL"] = "false" - cls._exporter = _StatsBeatExporter( - disable_offline_storage=True, - ) - cls._envelopes_to_export = [TelemetryItem(name="Test", time=datetime.now())] - - @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._statsbeat.collect_statsbeat_metrics") - def test_init(self, collect_mock): - exporter = _StatsBeatExporter(disable_offline_storage=True) - self.assertFalse(exporter._should_collect_stats()) - collect_mock.assert_not_called() - - def test_point_to_envelope(self): - resource = Resource.create(attributes={"asd": "test_resource"}) - point = NumberDataPoint( - start_time_unix_nano=1646865018558419456, - time_unix_nano=1646865018558419457, - value=10, - attributes={}, - ) - for ot_name, sb_name in _STATSBEAT_METRIC_NAME_MAPPINGS.items(): - envelope = self._exporter._point_to_envelope(point, ot_name, resource) - self.assertEqual(envelope.data.base_data.metrics[0].name, sb_name) - - def test_transmit_200_reach_ingestion(self): - _STATSBEAT_STATE["INITIAL_SUCCESS"] = False - with mock.patch.object(AzureMonitorClient, "track") as post: - post.return_value = TrackResponse( - items_received=1, - items_accepted=1, - errors=[], - ) - result = self._exporter._transmit(self._envelopes_to_export) - self.assertTrue(_STATSBEAT_STATE["INITIAL_SUCCESS"]) - self.assertEqual(result, ExportResult.SUCCESS) - - def test_transmit_206_reach_ingestion(self): - _STATSBEAT_STATE["INITIAL_SUCCESS"] = False - with mock.patch.object(AzureMonitorClient, "track") as post: - post.return_value = TrackResponse( - items_received=3, - items_accepted=1, - errors=[TelemetryErrorDetails(index=0, status_code=500, message="should retry")], - ) - result = self._exporter._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - self.assertTrue(_STATSBEAT_STATE["INITIAL_SUCCESS"]) - - def test_transmit_reach_ingestion_code(self): - _STATSBEAT_STATE["INITIAL_SUCCESS"] = False - with mock.patch("azure.monitor.opentelemetry.exporter.export._base._reached_ingestion_code") as m, mock.patch( - "azure.monitor.opentelemetry.exporter.export._base._is_retryable_code" - ) as p: - m.return_value = True - p.return_value = True - with mock.patch.object(AzureMonitorClient, "track", throw(HttpResponseError)): - result = self._exporter._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - self.assertTrue(_STATSBEAT_STATE["INITIAL_SUCCESS"]) - - def test_transmit_not_reach_ingestion_code(self): - _STATSBEAT_STATE["INITIAL_SUCCESS"] = False - _STATSBEAT_STATE["INITIAL_FAILURE_COUNT"] = 1 - with mock.patch("azure.monitor.opentelemetry.exporter.export._base._reached_ingestion_code") as m, mock.patch( - "azure.monitor.opentelemetry.exporter.export._base._is_retryable_code" - ) as p: - m.return_value = False - p.return_value = False - with mock.patch.object(AzureMonitorClient, "track", throw(HttpResponseError)): - result = self._exporter._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - self.assertFalse(_STATSBEAT_STATE["INITIAL_SUCCESS"]) - self.assertEqual(_STATSBEAT_STATE["INITIAL_FAILURE_COUNT"], 2) - - def test_transmit_not_reach_ingestion_exception(self): - _STATSBEAT_STATE["INITIAL_SUCCESS"] = False - _STATSBEAT_STATE["INITIAL_FAILURE_COUNT"] = 1 - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._statsbeat.shutdown_statsbeat_metrics") as m: - with mock.patch.object(AzureMonitorClient, "track", throw(Exception)): - result = self._exporter._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - self.assertFalse(_STATSBEAT_STATE["INITIAL_SUCCESS"]) - self.assertEqual(_STATSBEAT_STATE["INITIAL_FAILURE_COUNT"], 2) - m.assert_not_called() - - def test_transmit_not_reach_ingestion_exception_shutdown(self): - _STATSBEAT_STATE["INITIAL_SUCCESS"] = False - _STATSBEAT_STATE["INITIAL_FAILURE_COUNT"] = 2 - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._statsbeat.shutdown_statsbeat_metrics") as m: - with mock.patch.object(AzureMonitorClient, "track", throw(Exception)): - result = self._exporter._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - self.assertFalse(_STATSBEAT_STATE["INITIAL_SUCCESS"]) - self.assertEqual(_STATSBEAT_STATE["INITIAL_FAILURE_COUNT"], 3) - m.assert_called_once() diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_manager.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_manager.py new file mode 100644 index 000000000000..a995b400707d --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_manager.py @@ -0,0 +1,428 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import os +import unittest +from unittest import mock + +from azure.monitor.opentelemetry.exporter._constants import ( + _DEFAULT_EU_STATS_CONNECTION_STRING, + _DEFAULT_NON_EU_STATS_CONNECTION_STRING, +) +from azure.monitor.opentelemetry.exporter.statsbeat import StatsbeatConfig, _statsbeat +from azure.monitor.opentelemetry.exporter.statsbeat._manager import StatsbeatManager +from azure.monitor.opentelemetry.exporter.statsbeat._state import ( + _STATSBEAT_STATE, + _STATSBEAT_STATE_LOCK, +) +from azure.monitor.opentelemetry.exporter._constants import ( + _APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL, + _APPLICATIONINSIGHTS_STATS_CONNECTION_STRING_ENV_NAME, + _APPLICATIONINSIGHTS_STATS_SHORT_EXPORT_INTERVAL_ENV_NAME, + _APPLICATIONINSIGHTS_STATS_LONG_EXPORT_INTERVAL_ENV_NAME, +) + +# cSpell:disable + + +# pylint: disable=protected-access +class TestStatsbeat(unittest.TestCase): + def setUp(self): + os.environ[_APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL] = "false" + os.environ[_APPLICATIONINSIGHTS_STATS_LONG_EXPORT_INTERVAL_ENV_NAME] = "30" + os.environ[_APPLICATIONINSIGHTS_STATS_SHORT_EXPORT_INTERVAL_ENV_NAME] = "15" + StatsbeatManager().shutdown() + with _STATSBEAT_STATE_LOCK: + _STATSBEAT_STATE["INITIAL_FAILURE_COUNT"] = 0 + _STATSBEAT_STATE["INITIAL_SUCCESS"] = False + _STATSBEAT_STATE["SHUTDOWN"] = False + _STATSBEAT_STATE["CUSTOM_EVENTS_FEATURE_SET"] = False + _STATSBEAT_STATE["LIVE_METRICS_FEATURE_SET"] = False + + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager._StatsbeatMetrics") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.MeterProvider") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader") + @mock.patch("azure.monitor.opentelemetry.exporter.AzureMonitorMetricExporter") + def test_collect_statsbeat_metrics(self, mock_exporter, mock_reader, mock_meter_provider, mock_statsbeat_metrics): + """Test that collect_statsbeat_metrics properly initializes statsbeat collection.""" + # Arrange + exporter = mock.Mock() + exporter._endpoint = "https://westus-1.in.applicationinsights.azure.com/" + exporter._instrumentation_key = "1aa11111-bbbb-1ccc-8ddd-eeeeffff3334" + exporter._disable_offline_storage = False + exporter._credential = None + exporter._distro_version = "" + + # Set up mock returns + mock_exporter_instance = mock.Mock() + mock_exporter.return_value = mock_exporter_instance + + mock_reader_instance = mock.Mock() + mock_reader.return_value = mock_reader_instance + + mock_meter_provider_instance = mock.Mock() + mock_meter_provider.return_value = mock_meter_provider_instance + flush_mock = mock.Mock() + mock_meter_provider_instance.force_flush = flush_mock + + mock_statsbeat_metrics_instance = mock.Mock() + mock_statsbeat_metrics.return_value = mock_statsbeat_metrics_instance + + manager = StatsbeatManager() + self.assertFalse(manager._initialized) + + # Act + _statsbeat.collect_statsbeat_metrics(exporter) + + # Assert - verify manager is initialized + self.assertTrue(manager._initialized) + self.assertEqual(manager._metrics, mock_statsbeat_metrics_instance) + self.assertEqual(manager._meter_provider, mock_meter_provider_instance) + self.assertIsInstance(manager._config, StatsbeatConfig) + + # Verify configuration was created correctly from exporter + config = manager._config + self.assertEqual(config.endpoint, exporter._endpoint) + self.assertEqual(config.instrumentation_key, exporter._instrumentation_key) + self.assertEqual(config.disable_offline_storage, exporter._disable_offline_storage) + self.assertEqual(config.credential, exporter._credential) + self.assertEqual(config.distro_version, exporter._distro_version) + + # Verify statsbeat metrics creation + metrics = manager._metrics + mock_statsbeat_metrics.assert_called_once_with( + mock_meter_provider_instance, + exporter._instrumentation_key, + exporter._endpoint, + exporter._disable_offline_storage, + 2, + False, + exporter._distro_version + ) + + # Verify initialization methods were called + flush_mock.assert_called_once() + mock_statsbeat_metrics_instance.init_non_initial_metrics.assert_called_once() + + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager._StatsbeatMetrics") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.MeterProvider") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader") + @mock.patch("azure.monitor.opentelemetry.exporter.export.metrics._exporter.AzureMonitorMetricExporter") + def test_collect_statsbeat_metrics_exists(self, mock_exporter, mock_reader, mock_meter_provider, mock_statsbeat_metrics): + """Test that collect_statsbeat_metrics reuses existing configuration when called multiple times with same config.""" + # Arrange + exporter = mock.Mock() + exporter._endpoint = "test endpoint" + exporter._instrumentation_key = "test ikey" + exporter._disable_offline_storage = False + exporter._credential = None + exporter._distro_version = "" + + # Set up mock returns + mock_exporter_instance = mock.Mock() + mock_exporter.return_value = mock_exporter_instance + + mock_reader_instance = mock.Mock() + mock_reader.return_value = mock_reader_instance + + mock_meter_provider_instance = mock.Mock() + mock_meter_provider.return_value = mock_meter_provider_instance + flush_mock = mock.Mock() + mock_meter_provider_instance.force_flush = flush_mock + + mock_statsbeat_metrics_instance = mock.Mock() + mock_statsbeat_metrics.return_value = mock_statsbeat_metrics_instance + + manager = StatsbeatManager() + self.assertFalse(manager._initialized) + + # Act - Initialize first time + _statsbeat.collect_statsbeat_metrics(exporter) + first_metrics = manager._metrics + self.assertTrue(manager._initialized) + self.assertEqual(first_metrics, mock_statsbeat_metrics_instance) + + # Verify first initialization called the mocks + self.assertEqual(mock_statsbeat_metrics.call_count, 1) + self.assertEqual(mock_meter_provider.call_count, 1) + + # Act - Initialize second time with same config + _statsbeat.collect_statsbeat_metrics(exporter) + second_metrics = manager._metrics + + # Assert - should reuse existing config since it's the same + self.assertTrue(manager._initialized) + self.assertIsNotNone(second_metrics) + self.assertEqual(first_metrics, second_metrics) + + # Verify mocks were NOT called again since config is the same + self.assertEqual(mock_statsbeat_metrics.call_count, 1) # Still only called once + self.assertEqual(mock_meter_provider.call_count, 1) # Still only called once + + # Verify only one call to flush (from first initialization) + flush_mock.assert_called_once() + mock_statsbeat_metrics_instance.init_non_initial_metrics.assert_called_once() + + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager._StatsbeatMetrics") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.MeterProvider") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader") + @mock.patch("azure.monitor.opentelemetry.exporter.export.metrics._exporter.AzureMonitorMetricExporter") + def test_collect_statsbeat_metrics_non_eu(self, mock_exporter, mock_reader, mock_meter_provider, mock_statsbeat_metrics): + """Test collect_statsbeat_metrics with non-EU endpoint uses correct connection string.""" + # Arrange + exporter = mock.Mock() + exporter._instrumentation_key = "1aa11111-bbbb-1ccc-8ddd-eeeeffff3333" + exporter._endpoint = "https://westus-0.in.applicationinsights.azure.com/" + exporter._disable_offline_storage = False + exporter._credential = None + exporter._distro_version = "" + + # Set up mock returns + mock_exporter_instance = mock.Mock() + mock_exporter.return_value = mock_exporter_instance + + mock_reader_instance = mock.Mock() + mock_reader.return_value = mock_reader_instance + + mock_meter_provider_instance = mock.Mock() + mock_meter_provider.return_value = mock_meter_provider_instance + flush_mock = mock.Mock() + mock_meter_provider_instance.force_flush = flush_mock + + mock_statsbeat_metrics_instance = mock.Mock() + mock_statsbeat_metrics.return_value = mock_statsbeat_metrics_instance + + manager = StatsbeatManager() + self.assertFalse(manager._initialized) + + with mock.patch.dict( + os.environ, + { + _APPLICATIONINSIGHTS_STATS_CONNECTION_STRING_ENV_NAME: "", + }, + ): + # Act + _statsbeat.collect_statsbeat_metrics(exporter) + + # Assert + self.assertTrue(manager._initialized) + self.assertIsNotNone(manager._metrics) + + # Verify that AzureMonitorMetricExporter was called with the correct connection string + mock_exporter.assert_called_once() + call_args = mock_exporter.call_args + # The connection string should be the non-EU default since the endpoint is non-EU + expected_connection_string = call_args[1]['connection_string'] + self.assertIn(_DEFAULT_NON_EU_STATS_CONNECTION_STRING.split(";")[0].split("=")[1], expected_connection_string) + + + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager._StatsbeatMetrics") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.MeterProvider") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader") + @mock.patch("azure.monitor.opentelemetry.exporter.export.metrics._exporter.AzureMonitorMetricExporter") + def test_collect_statsbeat_metrics_eu(self, mock_exporter, mock_reader, mock_meter_provider, mock_statsbeat_metrics): + """Test collect_statsbeat_metrics with EU endpoint uses correct connection string.""" + # Arrange + exporter = mock.Mock() + exporter._instrumentation_key = "1aa11111-bbbb-1ccc-8ddd-eeeeffff3333" + exporter._endpoint = "https://northeurope-0.in.applicationinsights.azure.com/" + exporter._disable_offline_storage = False + exporter._credential = None + exporter._distro_version = "" + + # Set up mock returns + mock_exporter_instance = mock.Mock() + mock_exporter.return_value = mock_exporter_instance + + mock_reader_instance = mock.Mock() + mock_reader.return_value = mock_reader_instance + + mock_meter_provider_instance = mock.Mock() + mock_meter_provider.return_value = mock_meter_provider_instance + flush_mock = mock.Mock() + mock_meter_provider_instance.force_flush = flush_mock + + mock_statsbeat_metrics_instance = mock.Mock() + mock_statsbeat_metrics.return_value = mock_statsbeat_metrics_instance + + manager = StatsbeatManager() + self.assertFalse(manager._initialized) + + with mock.patch.dict( + os.environ, + { + _APPLICATIONINSIGHTS_STATS_CONNECTION_STRING_ENV_NAME: "", + }, + ): + # Act + _statsbeat.collect_statsbeat_metrics(exporter) + + # Assert + self.assertTrue(manager._initialized) + self.assertIsNotNone(manager._metrics) + + # Verify that AzureMonitorMetricExporter was called with the correct connection string + mock_exporter.assert_called_once() + call_args = mock_exporter.call_args + # The connection string should be the EU default since the endpoint is EU + expected_connection_string = call_args[1]['connection_string'] + self.assertIn(_DEFAULT_EU_STATS_CONNECTION_STRING.split(";")[0].split("=")[1], expected_connection_string) + + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager._StatsbeatMetrics") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.MeterProvider") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader") + @mock.patch("azure.monitor.opentelemetry.exporter.AzureMonitorMetricExporter") + def test_collect_statsbeat_metrics_aad(self, mock_exporter, mock_reader, mock_meter_provider, mock_statsbeat_metrics): + """Test collect_statsbeat_metrics with AAD credentials.""" + # Arrange + exporter = mock.Mock() + TEST_ENDPOINT = "test endpoint" + TEST_IKEY = "test ikey" + TEST_CREDENTIAL = "test credential" + exporter._endpoint = TEST_ENDPOINT + exporter._instrumentation_key = TEST_IKEY + exporter._disable_offline_storage = False + exporter._credential = TEST_CREDENTIAL + exporter._distro_version = "" + mp_mock = mock.Mock() + mock_meter_provider.return_value = mp_mock + + # Act + _statsbeat.collect_statsbeat_metrics(exporter) + + # Assert - Verify _StatsbeatMetrics was called with correct parameters + mock_statsbeat_metrics.assert_called_once_with( + mp_mock, + TEST_IKEY, + TEST_ENDPOINT, + False, + 2, # Expected threshold from setUp env vars (30/15 = 2) + True, # has_credential should be True + "", + ) + + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager._StatsbeatMetrics") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.MeterProvider") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader") + @mock.patch("azure.monitor.opentelemetry.exporter.AzureMonitorMetricExporter") + def test_collect_statsbeat_metrics_no_aad(self, mock_exporter, mock_reader, mock_meter_provider, mock_statsbeat_metrics): + """Test collect_statsbeat_metrics without AAD credentials.""" + # Arrange + exporter = mock.Mock() + TEST_ENDPOINT = "test endpoint" + TEST_IKEY = "test ikey" + TEST_CREDENTIAL = None + exporter._endpoint = TEST_ENDPOINT + exporter._instrumentation_key = TEST_IKEY + exporter._disable_offline_storage = False + exporter._credential = TEST_CREDENTIAL + exporter._distro_version = "" + mp_mock = mock.Mock() + mock_meter_provider.return_value = mp_mock + + # Act + _statsbeat.collect_statsbeat_metrics(exporter) + + # Assert - Verify _StatsbeatMetrics was called with correct parameters + mock_statsbeat_metrics.assert_called_once_with( + mp_mock, + TEST_IKEY, + TEST_ENDPOINT, + False, + 2, # Expected threshold from setUp env vars (30/15 = 2) + False, # has_credential should be False + "", + ) + + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager._StatsbeatMetrics") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.MeterProvider") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader") + @mock.patch("azure.monitor.opentelemetry.exporter.AzureMonitorMetricExporter") + def test_collect_statsbeat_metrics_distro_version(self, mock_exporter, mock_reader, mock_meter_provider, mock_statsbeat_metrics): + """Test collect_statsbeat_metrics with distribution version.""" + # Arrange + exporter = mock.Mock() + TEST_ENDPOINT = "test endpoint" + TEST_IKEY = "test ikey" + TEST_CREDENTIAL = None + exporter._endpoint = TEST_ENDPOINT + exporter._instrumentation_key = TEST_IKEY + exporter._disable_offline_storage = False + exporter._credential = TEST_CREDENTIAL + exporter._distro_version = "1.0.0" + mp_mock = mock.Mock() + mock_meter_provider.return_value = mp_mock + + # Act + _statsbeat.collect_statsbeat_metrics(exporter) + + # Assert - Verify _StatsbeatMetrics was called with correct parameters + mock_statsbeat_metrics.assert_called_once_with( + mp_mock, + TEST_IKEY, + TEST_ENDPOINT, + False, + 2, # Expected threshold from setUp env vars (30/15 = 2) + False, # has_credential should be False + "1.0.0", # distro_version should be passed through + ) + + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager._StatsbeatMetrics") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.MeterProvider") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader") + @mock.patch("azure.monitor.opentelemetry.exporter.AzureMonitorMetricExporter") + def test_shutdown_statsbeat_metrics(self, mock_exporter, mock_reader, mock_meter_provider, mock_statsbeat_metrics): + """Test shutdown_statsbeat_metrics after initialization.""" + # Arrange - First initialize statsbeat + exporter = mock.Mock() + exporter._endpoint = "test endpoint" + exporter._instrumentation_key = "test ikey" + exporter._disable_offline_storage = False + exporter._credential = None + exporter._distro_version = "" + + # Set up mock returns + mock_exporter_instance = mock.Mock() + mock_exporter.return_value = mock_exporter_instance + + mock_reader_instance = mock.Mock() + mock_reader.return_value = mock_reader_instance + + mock_meter_provider_instance = mock.Mock() + mock_meter_provider.return_value = mock_meter_provider_instance + flush_mock = mock.Mock() + shutdown_mock = mock.Mock(return_value=True) + mock_meter_provider_instance.force_flush = flush_mock + mock_meter_provider_instance.shutdown = shutdown_mock + + mock_statsbeat_metrics_instance = mock.Mock() + mock_statsbeat_metrics.return_value = mock_statsbeat_metrics_instance + + manager = StatsbeatManager() + + # Act - Initialize first + _statsbeat.collect_statsbeat_metrics(exporter) + self.assertTrue(manager._initialized) + self.assertFalse(_STATSBEAT_STATE["SHUTDOWN"]) + + # Act - Test shutdown + result = _statsbeat.shutdown_statsbeat_metrics() + + # Assert + self.assertTrue(result) + self.assertFalse(manager._initialized) + self.assertTrue(_STATSBEAT_STATE["SHUTDOWN"]) + + def test_shutdown_statsbeat_metrics_not_initialized(self): + """Test shutdown when statsbeat is not initialized.""" + # Arrange + manager = StatsbeatManager() + self.assertFalse(manager._initialized) + + # Act - Test shutdown when not initialized + result = _statsbeat.shutdown_statsbeat_metrics() + + # Assert + self.assertFalse(result) # Should return False when not initialized + self.assertFalse(manager._initialized) + +# cSpell:enable diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_statsbeat.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py similarity index 80% rename from sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_statsbeat.py rename to sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py index a60ed90916c3..622780e60aee 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_statsbeat.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py @@ -9,13 +9,9 @@ from unittest import mock from opentelemetry.sdk.metrics import Meter, MeterProvider, ObservableGauge -from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader -from opentelemetry.sdk.resources import Resource from azure.monitor.opentelemetry.exporter._constants import ( _ATTACH_METRIC_NAME, - _DEFAULT_EU_STATS_CONNECTION_STRING, - _DEFAULT_NON_EU_STATS_CONNECTION_STRING, _FEATURE_METRIC_NAME, _REQ_DURATION_NAME, _REQ_EXCEPTION_NAME, @@ -24,18 +20,10 @@ _REQ_SUCCESS_NAME, _REQ_THROTTLE_NAME, ) -from azure.monitor.opentelemetry.exporter.statsbeat import _statsbeat -from azure.monitor.opentelemetry.exporter.statsbeat._exporter import _StatsBeatExporter from azure.monitor.opentelemetry.exporter.statsbeat._state import ( _REQUESTS_MAP, _STATSBEAT_STATE, -) -from azure.monitor.opentelemetry.exporter._constants import ( - _APPLICATIONINSIGHTS_STATS_CONNECTION_STRING_ENV_NAME, - _DEFAULT_STATS_LONG_EXPORT_INTERVAL, - _DEFAULT_STATS_SHORT_EXPORT_INTERVAL, - _APPLICATIONINSIGHTS_STATS_SHORT_EXPORT_INTERVAL_ENV_NAME, - _APPLICATIONINSIGHTS_STATS_LONG_EXPORT_INTERVAL_ENV_NAME, + _STATSBEAT_STATE_LOCK, ) from azure.monitor.opentelemetry.exporter.statsbeat._statsbeat_metrics import ( _shorten_host, @@ -62,207 +50,11 @@ def func(*_args, **_kwargs): # cSpell:disable - -# pylint: disable=protected-access -class TestStatsbeat(unittest.TestCase): - def setUp(self): - _statsbeat._STATSBEAT_METRICS = None - _STATSBEAT_STATE["SHUTDOWN"] = False - - @mock.patch.object(MeterProvider, "shutdown") - @mock.patch.object(MeterProvider, "force_flush") - @mock.patch.object(_StatsbeatMetrics, "init_non_initial_metrics") - def test_collect_statsbeat_metrics(self, non_init_mock, flush_mock, shutdown_mock): - exporter = mock.Mock() - exporter._endpoint = "test endpoint" - exporter._instrumentation_key = "test ikey" - self.assertIsNone(_statsbeat._STATSBEAT_METRICS) - _statsbeat.collect_statsbeat_metrics(exporter) - mp = _statsbeat._STATSBEAT_METRICS._meter_provider - self.assertTrue(isinstance(mp, MeterProvider)) - self.assertEqual(mp._sdk_config.resource, Resource.get_empty()) - self.assertTrue(len(mp._sdk_config.metric_readers), 1) - mr = mp._sdk_config.metric_readers[0] - self.assertTrue(isinstance(mr, PeriodicExportingMetricReader)) - self.assertIsNotNone(mr._exporter) - self.assertTrue(isinstance(mr._exporter, _StatsBeatExporter)) - non_init_mock.assert_called_once() - flush_mock.assert_called_once() - - def test_collect_statsbeat_metrics_exists(self): - exporter = mock.Mock() - mock_metrics = mock.Mock() - self.assertIsNone(_statsbeat._STATSBEAT_METRICS) - _statsbeat._STATSBEAT_METRICS = mock_metrics - _statsbeat.collect_statsbeat_metrics(exporter) - self.assertEqual(_statsbeat._STATSBEAT_METRICS, mock_metrics) - - @mock.patch.object(MeterProvider, "shutdown") - @mock.patch.object(MeterProvider, "force_flush") - @mock.patch.object(_StatsbeatMetrics, "init_non_initial_metrics") - def test_collect_statsbeat_metrics_non_eu(self, non_init_mock, flush_mock, shutdown_mock): - exporter = mock.Mock() - exporter._instrumentation_key = "1aa11111-bbbb-1ccc-8ddd-eeeeffff3333" - exporter._endpoint = "https://westus-0.in.applicationinsights.azure.com/" - self.assertIsNone(_statsbeat._STATSBEAT_METRICS) - with mock.patch.dict( - os.environ, - { - _APPLICATIONINSIGHTS_STATS_CONNECTION_STRING_ENV_NAME: "", - }, - ): - _statsbeat.collect_statsbeat_metrics(exporter) - self.assertIsNotNone(_statsbeat._STATSBEAT_METRICS) - mp = _statsbeat._STATSBEAT_METRICS._meter_provider - mr = mp._sdk_config.metric_readers[0] - stats_exporter = mr._exporter - self.assertEqual( - stats_exporter._instrumentation_key, _DEFAULT_NON_EU_STATS_CONNECTION_STRING.split(";")[0].split("=")[1] - ) - self.assertEqual( - stats_exporter._endpoint, _DEFAULT_NON_EU_STATS_CONNECTION_STRING.split(";")[1].split("=")[1] # noqa: E501 - ) - - @mock.patch.object(MeterProvider, "shutdown") - @mock.patch.object(MeterProvider, "force_flush") - @mock.patch.object(_StatsbeatMetrics, "init_non_initial_metrics") - def test_collect_statsbeat_metrics_eu(self, non_init_mock, flush_mock, shutdown_mock): - exporter = mock.Mock() - exporter._instrumentation_key = "1aa11111-bbbb-1ccc-8ddd-eeeeffff3333" - exporter._endpoint = "https://northeurope-0.in.applicationinsights.azure.com/" - self.assertIsNone(_statsbeat._STATSBEAT_METRICS) - with mock.patch.dict( - os.environ, - { - _APPLICATIONINSIGHTS_STATS_CONNECTION_STRING_ENV_NAME: "", - }, - ): - _statsbeat.collect_statsbeat_metrics(exporter) - self.assertIsNotNone(_statsbeat._STATSBEAT_METRICS) - mp = _statsbeat._STATSBEAT_METRICS._meter_provider - mr = mp._sdk_config.metric_readers[0] - stats_exporter = mr._exporter - self.assertEqual( - stats_exporter._instrumentation_key, _DEFAULT_EU_STATS_CONNECTION_STRING.split(";")[0].split("=")[1] - ) - self.assertEqual( - stats_exporter._endpoint, _DEFAULT_EU_STATS_CONNECTION_STRING.split(";")[1].split("=")[1] # noqa: E501 - ) - - @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._statsbeat._StatsbeatMetrics") - @mock.patch.dict( - "os.environ", - { - _APPLICATIONINSIGHTS_STATS_SHORT_EXPORT_INTERVAL_ENV_NAME: "", - _APPLICATIONINSIGHTS_STATS_LONG_EXPORT_INTERVAL_ENV_NAME: "", - }, - ) - def test_collect_statsbeat_metrics_aad( - self, - mock_statsbeat_metrics, - ): - exporter = mock.Mock() - TEST_ENDPOINT = "test endpoint" - TEST_IKEY = "test ikey" - TEST_CREDENTIAL = "test credential" - exporter._endpoint = TEST_ENDPOINT - exporter._instrumentation_key = TEST_IKEY - exporter._disable_offline_storage = False - exporter._credential = TEST_CREDENTIAL - exporter._distro_version = "" - _statsbeat.collect_statsbeat_metrics(exporter) - mock_statsbeat_metrics.assert_called_once_with( - mock.ANY, - TEST_IKEY, - TEST_ENDPOINT, - False, - _DEFAULT_STATS_LONG_EXPORT_INTERVAL / _DEFAULT_STATS_SHORT_EXPORT_INTERVAL, - True, - "", - ) - - @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._statsbeat._StatsbeatMetrics") - @mock.patch.dict( - "os.environ", - { - _APPLICATIONINSIGHTS_STATS_SHORT_EXPORT_INTERVAL_ENV_NAME: "", - _APPLICATIONINSIGHTS_STATS_LONG_EXPORT_INTERVAL_ENV_NAME: "", - }, - ) - def test_collect_statsbeat_metrics_no_aad( - self, - mock_statsbeat_metrics, - ): - exporter = mock.Mock() - TEST_ENDPOINT = "test endpoint" - TEST_IKEY = "test ikey" - TEST_CREDENTIAL = None - exporter._endpoint = TEST_ENDPOINT - exporter._instrumentation_key = TEST_IKEY - exporter._disable_offline_storage = False - exporter._credential = TEST_CREDENTIAL - exporter._distro_version = "" - _statsbeat.collect_statsbeat_metrics(exporter) - mock_statsbeat_metrics.assert_called_once_with( - mock.ANY, - TEST_IKEY, - TEST_ENDPOINT, - False, - _DEFAULT_STATS_LONG_EXPORT_INTERVAL / _DEFAULT_STATS_SHORT_EXPORT_INTERVAL, - False, - "", - ) - - @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._statsbeat._StatsbeatMetrics") - @mock.patch.dict( - "os.environ", - { - _APPLICATIONINSIGHTS_STATS_SHORT_EXPORT_INTERVAL_ENV_NAME: "", - _APPLICATIONINSIGHTS_STATS_LONG_EXPORT_INTERVAL_ENV_NAME: "", - }, - ) - def test_collect_statsbeat_metrics_distro_version( - self, - mock_statsbeat_metrics, - ): - exporter = mock.Mock() - TEST_ENDPOINT = "test endpoint" - TEST_IKEY = "test ikey" - TEST_CREDENTIAL = None - exporter._endpoint = TEST_ENDPOINT - exporter._instrumentation_key = TEST_IKEY - exporter._disable_offline_storage = False - exporter._credential = TEST_CREDENTIAL - exporter._distro_version = "1.0.0" - _statsbeat.collect_statsbeat_metrics(exporter) - mock_statsbeat_metrics.assert_called_once_with( - mock.ANY, - TEST_IKEY, - TEST_ENDPOINT, - False, - _DEFAULT_STATS_LONG_EXPORT_INTERVAL / _DEFAULT_STATS_SHORT_EXPORT_INTERVAL, - False, - "1.0.0", - ) - - def test_shutdown_statsbeat_metrics(self): - metric_mock = mock.Mock() - mp_mock = mock.Mock() - metric_mock._meter_provider = mp_mock - _statsbeat._STATSBEAT_METRICS = metric_mock - self.assertFalse(_STATSBEAT_STATE["SHUTDOWN"]) - _statsbeat.shutdown_statsbeat_metrics() - mp_mock.shutdown.assert_called_once() - self.assertIsNone(_statsbeat._STATSBEAT_METRICS) - self.assertTrue(_STATSBEAT_STATE["SHUTDOWN"]) - - _StatsbeatMetrics_COMMON_ATTRS = dict(_StatsbeatMetrics._COMMON_ATTRIBUTES) _StatsbeatMetrics_NETWORK_ATTRS = dict(_StatsbeatMetrics._NETWORK_ATTRIBUTES) _StatsbeatMetrics_FEATURE_ATTRIBUTES = dict(_StatsbeatMetrics._FEATURE_ATTRIBUTES) _StatsbeatMetrics_INSTRUMENTATION_ATTRIBUTES = dict(_StatsbeatMetrics._INSTRUMENTATION_ATTRIBUTES) - # pylint: disable=protected-access class TestStatsbeatMetrics(unittest.TestCase): @classmethod @@ -287,17 +79,18 @@ def setUpClass(cls): ) def setUp(self): - _statsbeat._STATSBEAT_METRICS = None + with _STATSBEAT_STATE_LOCK: + _STATSBEAT_STATE["INITIAL_FAILURE_COUNT"] = 0 + _STATSBEAT_STATE["INITIAL_SUCCESS"] = False + _STATSBEAT_STATE["SHUTDOWN"] = False + _STATSBEAT_STATE["CUSTOM_EVENTS_FEATURE_SET"] = False + _STATSBEAT_STATE["LIVE_METRICS_FEATURE_SET"] = False + _StatsbeatMetrics._COMMON_ATTRIBUTES = dict(_StatsbeatMetrics_COMMON_ATTRS) _StatsbeatMetrics._NETWORK_ATTRIBUTES = dict(_StatsbeatMetrics_NETWORK_ATTRS) _StatsbeatMetrics._FEATURE_ATTRIBUTES = dict(_StatsbeatMetrics_FEATURE_ATTRIBUTES) _StatsbeatMetrics._INSTRUMENTATION_ATTRIBUTES = dict(_StatsbeatMetrics_INSTRUMENTATION_ATTRIBUTES) _REQUESTS_MAP.clear() - _STATSBEAT_STATE["INITIAL_FAILURE_COUNT"] = 0 - _STATSBEAT_STATE["INITIAL_SUCCESS"] = False - _STATSBEAT_STATE["SHUTDOWN"] = False - _STATSBEAT_STATE["CUSTOM_EVENTS_FEATURE_SET"] = False - _STATSBEAT_STATE["LIVE_METRICS_FEATURE_SET"] = False def test_statsbeat_metric_init(self): mp = MeterProvider() @@ -1102,5 +895,4 @@ def test_shorten_host(self): url = "http://fakehost-5/" self.assertEqual(_shorten_host(url), "fakehost-5") - # cSpell:enable diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/test_base_customer_sdkstats.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/test_base_customer_sdkstats.py new file mode 100644 index 000000000000..f1601cfc7dca --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/test_base_customer_sdkstats.py @@ -0,0 +1,336 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +import shutil +import unittest +from unittest import mock +from datetime import datetime + +from azure.core.exceptions import HttpResponseError, ServiceRequestError +from azure.monitor.opentelemetry.exporter.export._base import ( + BaseExporter, + ExportResult, +) +from azure.monitor.opentelemetry.exporter._generated import AzureMonitorClient +from azure.monitor.opentelemetry.exporter._generated.models import ( + TelemetryItem, + TrackResponse, + TelemetryErrorDetails, +) +from azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats import ( + CustomerSdkStatsMetrics, + DropCode, +) + + +class MockResponse: + """Mock response object for HTTP requests""" + def __init__(self, status_code, content): + self.status_code = status_code + self.content = content + self.text = content + self.headers = {} + self.raw = mock.Mock() # Add the raw attribute that Azure SDK expects + self.raw.enforce_content_length = True + self.reason = "Mock Reason" # Add the reason attribute + self.url = "http://mock-url.com" # Add the url attribute + + +class TestBaseExporterCustomerSdkStats(unittest.TestCase): + """Test integration between BaseExporter and customer sdkstats tracking functions""" + + def setUp(self): + from azure.monitor.opentelemetry.exporter._generated.models import TelemetryEventData + self._envelopes_to_export = [ + TelemetryItem( + name="test_envelope", + time=datetime.now(), + data=TelemetryEventData( + name="test_event", + properties={"test_property": "test_value"} + ), + tags={"ai.internal.sdkVersion": "test_version"}, + instrumentation_key="test_key", + ) + ] + + def tearDown(self): + # Clean up any environment variables + for key in ["APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW"]: + if key in os.environ: + del os.environ[key] + # Clean up any temp directories + if hasattr(self, "_temp_dir") and os.path.exists(self._temp_dir): + shutil.rmtree(self._temp_dir, ignore_errors=True) + + def _create_exporter_with_customer_sdkstats_enabled(self, disable_offline_storage=True): + """Helper method to create an exporter with customer sdkstats enabled""" + # Mock the customer sdkstats metrics from the correct import location + with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.CustomerSdkStatsMetrics") as customer_sdkstats_mock: + customer_sdkstats_instance = mock.Mock(spec=CustomerSdkStatsMetrics) + customer_sdkstats_mock.return_value = customer_sdkstats_instance + + exporter = BaseExporter( + connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", + disable_offline_storage=disable_offline_storage, + ) + + # Set up the mocked customer sdkstats metrics instance + exporter._customer_sdkstats_metrics = customer_sdkstats_instance + + # Mock the should_collect method to return True + exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) + + return exporter + + @mock.patch.dict( + os.environ, + { + "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", + }, + ) + def test_customer_sdkstats_feature_flag_disabled(self): + """Test that customer sdkstats tracking is not called when feature flag is disabled""" + # Remove the environment variable to simulate disabled state + del os.environ["APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW"] + + exporter = BaseExporter(connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd") + # Verify that customer sdkstats metrics is None when feature is disabled + self.assertIsNone(exporter._customer_sdkstats_metrics) + self.assertFalse(exporter._should_collect_customer_sdkstats()) + + @mock.patch.dict( + os.environ, + { + "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", + }, + ) + @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_successful_items") + def test_transmit_200_customer_sdkstats_track_successful_items(self, track_successful_mock): + """Test that _track_successful_items is called on 200 success response""" + exporter = self._create_exporter_with_customer_sdkstats_enabled() + + with mock.patch.object(AzureMonitorClient, "track") as track_mock: + track_response = TrackResponse( + items_received=1, + items_accepted=1, + errors=[], + ) + track_mock.return_value = track_response + result = exporter._transmit(self._envelopes_to_export) + + track_successful_mock.assert_called_once_with(exporter._customer_sdkstats_metrics, self._envelopes_to_export) + self.assertEqual(result, ExportResult.SUCCESS) + + @mock.patch.dict( + os.environ, + { + "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", + }, + ) + @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_retry_items") + def test_transmit_206_customer_sdkstats_track_retry_items(self, track_retry_mock): + """Test that _track_retry_items is called on 206 partial success with retryable errors""" + exporter = self._create_exporter_with_customer_sdkstats_enabled() + with mock.patch.object(AzureMonitorClient, "track") as track_mock: + track_mock.return_value = TrackResponse( + items_received=2, + items_accepted=1, + errors=[ + TelemetryErrorDetails(index=0, status_code=500, message="should retry"), + ], + ) + result = exporter._transmit(self._envelopes_to_export * 2) + + track_retry_mock.assert_called_once() + # With storage disabled by default, retryable errors become non-retryable + self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) + + @mock.patch.dict( + os.environ, + { + "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", + }, + ) + @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") + def test_transmit_206_customer_sdkstats_track_dropped_items(self, track_dropped_mock): + """Test that _track_dropped_items is called on 206 partial success with non-retryable errors""" + exporter = self._create_exporter_with_customer_sdkstats_enabled() + with mock.patch.object(AzureMonitorClient, "track") as track_mock: + track_mock.return_value = TrackResponse( + items_received=2, + items_accepted=1, + errors=[ + TelemetryErrorDetails(index=0, status_code=400, message="should drop"), + ], + ) + result = exporter._transmit(self._envelopes_to_export * 2) + + track_dropped_mock.assert_called_once() + self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) + + @mock.patch.dict( + os.environ, + { + "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", + }, + ) + @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_retry_items") + def test_transmit_retryable_http_error_customer_sdkstats_track_retry_items(self, track_retry_mock): + """Test that _track_retry_items is called on retryable HTTP errors (e.g., 408, 502, 503, 504)""" + exporter = self._create_exporter_with_customer_sdkstats_enabled() + with mock.patch("requests.Session.request") as request_mock: + request_mock.return_value = MockResponse(408, "{}") + result = exporter._transmit(self._envelopes_to_export) + + track_retry_mock.assert_called_once() + self.assertEqual(result, ExportResult.FAILED_RETRYABLE) + + @mock.patch.dict( + os.environ, + { + "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", + }, + ) + @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") + def test_transmit_throttle_http_error_customer_sdkstats_track_dropped_items(self, track_dropped_mock): + """Test that _track_dropped_items is called on throttle HTTP errors (e.g., 402, 439)""" + exporter = self._create_exporter_with_customer_sdkstats_enabled() + + # Simulate a throttle HTTP error using HttpResponseError + with mock.patch.object(AzureMonitorClient, "track") as track_mock: + error_response = mock.Mock() + error_response.status_code = 402 # Use actual throttle code + track_mock.side_effect = HttpResponseError("Throttling error", response=error_response) + result = exporter._transmit(self._envelopes_to_export) + + track_dropped_mock.assert_called_once() + self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) + + @mock.patch.dict( + os.environ, + { + "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", + }, + ) + @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") + def test_transmit_invalid_http_error_customer_sdkstats_track_dropped_items_and_shutdown(self, track_dropped_mock): + """Test that _track_dropped_items is called and customer sdkstats is shutdown on invalid HTTP errors (e.g., 400)""" + exporter = self._create_exporter_with_customer_sdkstats_enabled() + with mock.patch("requests.Session.request") as request_mock, \ + mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.shutdown_customer_sdkstats_metrics") as shutdown_mock: + request_mock.return_value = MockResponse(400, "{}") + result = exporter._transmit(self._envelopes_to_export) + + track_dropped_mock.assert_called_once() + shutdown_mock.assert_called_once() + self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) + + @mock.patch.dict( + os.environ, + { + "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", + }, + ) + @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_retry_items") + def test_transmit_service_request_error_customer_sdkstats_track_retry_items(self, track_retry_mock): + """Test that _track_retry_items is called on ServiceRequestError""" + exporter = self._create_exporter_with_customer_sdkstats_enabled() + with mock.patch.object(AzureMonitorClient, "track", side_effect=ServiceRequestError("Connection error")): + result = exporter._transmit(self._envelopes_to_export) + + track_retry_mock.assert_called_once() + self.assertEqual(result, ExportResult.FAILED_RETRYABLE) + + @mock.patch.dict( + os.environ, + { + "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", + }, + ) + @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") + def test_transmit_general_exception_customer_sdkstats_track_dropped_items(self, track_dropped_mock): + """Test that _track_dropped_items is called on general exceptions""" + exporter = self._create_exporter_with_customer_sdkstats_enabled() + with mock.patch.object(AzureMonitorClient, "track", side_effect=Exception("General error")): + result = exporter._transmit(self._envelopes_to_export) + + track_dropped_mock.assert_called_once() + # Verify called with CLIENT_EXCEPTION drop code and error message + args, kwargs = track_dropped_mock.call_args + self.assertEqual(args[2], DropCode.CLIENT_EXCEPTION) + self.assertEqual(args[3], "General error") + self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) + + @mock.patch.dict( + os.environ, + { + "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", + }, + ) + @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") + def test_transmit_storage_disabled_customer_sdkstats_track_dropped_items(self, track_dropped_mock): + """Test that _track_dropped_items is called when offline storage is disabled and items would be retried""" + exporter = self._create_exporter_with_customer_sdkstats_enabled() + with mock.patch.object(AzureMonitorClient, "track") as track_mock: + track_mock.return_value = TrackResponse( + items_received=1, + items_accepted=0, + errors=[ + TelemetryErrorDetails(index=0, status_code=500, message="should retry but storage disabled"), + ], + ) + result = exporter._transmit(self._envelopes_to_export) + + track_dropped_mock.assert_called_once() + # Verify called with CLIENT_STORAGE_DISABLED drop code + args, kwargs = track_dropped_mock.call_args + self.assertEqual(args[2], DropCode.CLIENT_STORAGE_DISABLED) + self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) + + @mock.patch.dict( + os.environ, + { + "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", + }, + ) + @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage") + def test_transmit_from_storage_customer_sdkstats_track_dropped_items_from_storage(self, track_dropped_storage_mock): + """Test that _track_dropped_items_from_storage is called during storage operations""" + exporter = self._create_exporter_with_customer_sdkstats_enabled(disable_offline_storage=False) + + # Simulate a scenario where storage operations would happen + with mock.patch.object(AzureMonitorClient, "track") as track_mock: + track_mock.return_value = TrackResponse( + items_received=1, + items_accepted=0, + errors=[ + TelemetryErrorDetails(index=0, status_code=500, message="should retry"), + ], + ) + + # Mock the storage to simulate storage operations + with mock.patch.object(exporter.storage, "put") as put_mock, \ + mock.patch.object(exporter.storage, "gets", return_value=["stored_envelope"]) as gets_mock: + result = exporter._transmit(self._envelopes_to_export) + + track_dropped_storage_mock.assert_called_once() + self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) # Storage makes it NOT_RETRYABLE + + def test_should_collect_customer_sdkstats_with_metrics(self): + """Test _should_collect_customer_sdkstats returns True when metrics exist and feature is enabled""" + with mock.patch.dict(os.environ, {"APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true"}): + exporter = self._create_exporter_with_customer_sdkstats_enabled() + self.assertTrue(exporter._should_collect_customer_sdkstats()) + + def test_should_collect_customer_sdkstats_without_metrics(self): + """Test _should_collect_customer_sdkstats returns False when no metrics exist""" + # Don't patch the environment variable - let it be disabled by default + exporter = BaseExporter(connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd") + exporter._customer_sdkstats_metrics = None + self.assertFalse(exporter._should_collect_customer_sdkstats()) + + +if __name__ == "__main__": + unittest.main() diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/test_base_exporter.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/test_base_exporter.py index 1cb8d67d0235..cbe4c7e7b0ed 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/test_base_exporter.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/test_base_exporter.py @@ -23,7 +23,6 @@ from azure.monitor.opentelemetry.exporter.statsbeat._state import _REQUESTS_MAP, _STATSBEAT_STATE, _LOCAL_STORAGE_SETUP_STATE from azure.monitor.opentelemetry.exporter.statsbeat import _customer_sdkstats from azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats import _CUSTOMER_SDKSTATS_STATE, CustomerSdkStatsMetrics -from azure.monitor.opentelemetry.exporter.statsbeat._exporter import _StatsBeatExporter from azure.monitor.opentelemetry.exporter.export.metrics._exporter import AzureMonitorMetricExporter from azure.monitor.opentelemetry.exporter.export.trace._exporter import AzureMonitorTraceExporter from azure.monitor.opentelemetry.exporter._constants import ( @@ -123,27 +122,9 @@ def setUp(self) -> None: def tearDown(self): clean_folder(self._base.storage._path) - # @mock.patch('azure.monitor.opentelemetry.exporter.export._base._ConfigurationManager') - # def test_base_exporter_calls_configuration_manager(self, mock_config_manager): - # """Test that BaseExporter creates and uses ConfigurationManager.""" - # # Configure the mock - # mock_manager_instance = mock.Mock() - # mock_config_manager.return_value = mock_manager_instance - - # # Create BaseExporter instance - # base = BaseExporter( - # connection_string="InstrumentationKey=4321abcd-5678-4efa-8abc-1234567890ab;IngestionEndpoint=https://westus-0.in.applicationinsights.azure.com/", - # disable_offline_storage=True - # ) - - # # Verify ConfigurationManager was called - # mock_config_manager.assert_called_once() - - # # Verify the manager instance is accessible (if needed for future operations) - # self.assertIsNotNone(base) - - # # Optionally verify it was assigned to the instance - # self.assertEqual(base._configuration_manager, mock_manager_instance) + # ======================================================================== + # CONSTRUCTOR AND INITIALIZATION TESTS + # ======================================================================== def test_constructor(self): """Test the constructor.""" @@ -276,6 +257,10 @@ def test_constructor_disable_offline_storage_with_storage_directory(self, mock_g self.assertEqual(base._storage_directory, "test/path") mock_get_temp_dir.assert_not_called() + # ======================================================================== + # STORAGE TESTS + # ======================================================================== + @mock.patch("azure.monitor.opentelemetry.exporter.export._base._format_storage_telemetry_item") @mock.patch.object(TelemetryItem, "from_dict") def test_transmit_from_storage_success(self, dict_patch, format_patch): @@ -503,6 +488,231 @@ def test_format_storage_telemetry_item(self): self.assertEqual(format_ti.data.base_type, "RequestData") self.assertEqual(req_data.__dict__.items(), format_ti.data.base_data.__dict__.items()) + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._track_dropped_items") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._track_dropped_items") + def test_handle_transmit_from_storage_success_result(self, mock_track_dropped1, mock_track_dropped2): + """Test that when storage.put() returns StorageExportResult.LOCAL_FILE_BLOB_SUCCESS, + the method continues without any special handling.""" + exporter = BaseExporter(disable_offline_storage=False) + mock_customer_sdkstats = mock.Mock() + exporter._customer_sdkstats_metrics = mock_customer_sdkstats + exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) + + # Mock storage.put() to return success + exporter.storage = mock.Mock() + exporter.storage.put.return_value = StorageExportResult.LOCAL_FILE_BLOB_SUCCESS + + test_envelopes = [TelemetryItem(name="test", time=datetime.now())] + serialized_envelopes = [envelope.as_dict() for envelope in test_envelopes] + exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) + + # Verify storage.put was called with the serialized envelopes + exporter.storage.put.assert_called_once_with(serialized_envelopes) + # Verify that no dropped items were tracked (since it was a success) + mock_track_dropped1.assert_not_called() + mock_track_dropped2.assert_not_called() + # Verify that the customer sdkstats wasn't invoked + mock_customer_sdkstats.assert_not_called() + + def test_handle_transmit_from_storage_success_triggers_transmit(self): + exporter = BaseExporter(disable_offline_storage=False) + + with mock.patch.object(exporter, '_transmit_from_storage') as mock_transmit_from_storage: + test_envelopes = [TelemetryItem(name="test", time=datetime.now())] + + exporter._handle_transmit_from_storage(test_envelopes, ExportResult.SUCCESS) + + mock_transmit_from_storage.assert_called_once() + + def test_handle_transmit_from_storage_no_storage(self): + exporter = BaseExporter(disable_offline_storage=True) + + self.assertIsNone(exporter.storage) + + test_envelopes = [TelemetryItem(name="test", time=datetime.now())] + + exporter._handle_transmit_from_storage(test_envelopes, ExportResult.SUCCESS) + exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) + + def test_transmit_from_storage_no_storage(self): + exporter = BaseExporter(disable_offline_storage=True) + + self.assertIsNone(exporter.storage) + + exporter._transmit_from_storage() + + def test_local_storage_state_exception_get_set_operations(self): + """Test the validity of get and set operations for exception state in local storage state""" + from azure.monitor.opentelemetry.exporter.statsbeat._state import ( + get_local_storage_setup_state_exception, + set_local_storage_setup_state_exception, + _LOCAL_STORAGE_SETUP_STATE, + _LOCAL_STORAGE_SETUP_STATE_LOCK + ) + + # Save original state + original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] + + try: + # Test 1: Initial state should be None + self.assertEqual(get_local_storage_setup_state_exception(), "") + + # Test 2: Set string value and verify get operation + test_error = "Test storage exception" + set_local_storage_setup_state_exception(test_error) + self.assertEqual(get_local_storage_setup_state_exception(), test_error) + + # Test 3: Set empty string and verify get operation + set_local_storage_setup_state_exception("") + self.assertEqual(get_local_storage_setup_state_exception(), "") + + # Test 4: Set complex error message and verify get operation + complex_error = "OSError: [Errno 28] No space left on device: '/tmp/storage/file.blob'" + set_local_storage_setup_state_exception(complex_error) + self.assertEqual(get_local_storage_setup_state_exception(), complex_error) + + # Test 5: Verify thread safety by directly accessing state + with _LOCAL_STORAGE_SETUP_STATE_LOCK: + direct_value = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] + self.assertEqual(direct_value, complex_error) + self.assertEqual(get_local_storage_setup_state_exception(), direct_value) + + # Test 6: Test multiple rapid set/get operations + test_values = [ + "Error 1", + "Error 2", + "Error 3", + "", + "Final error" + ] + + for value in test_values: + with self.subTest(value=value): + set_local_storage_setup_state_exception(value) + self.assertEqual(get_local_storage_setup_state_exception(), value) + + # Test 8: Verify that set operation doesn't affect other state values + original_readonly = _LOCAL_STORAGE_SETUP_STATE["READONLY"] + set_local_storage_setup_state_exception("New exception") + self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["READONLY"], original_readonly) + self.assertEqual(get_local_storage_setup_state_exception(), "New exception") + + finally: + # Restore original state + _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state + + def test_local_storage_state_exception_concurrent_access(self): + """Test concurrent access to exception state get/set operations""" + import threading + import time + from azure.monitor.opentelemetry.exporter.statsbeat._state import ( + get_local_storage_setup_state_exception, + set_local_storage_setup_state_exception, + _LOCAL_STORAGE_SETUP_STATE + ) + + # Save original state + original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] + results = [] + errors = [] + + def worker_thread(thread_id): + try: + for i in range(10): + # Set a unique value + value = f"Thread-{thread_id}-Error-{i}" + set_local_storage_setup_state_exception(value) + + # Small delay to increase chance of race conditions + time.sleep(0.001) + + # Get the value and verify it's either our value or another thread's value + retrieved_value = get_local_storage_setup_state_exception() + results.append((thread_id, i, value, retrieved_value)) + + # Verify it's a valid value (either ours or from another thread) + if retrieved_value is not None: + self.assertIsInstance(retrieved_value, str) + self.assertTrue(retrieved_value.startswith("Thread-")) + except Exception as e: + errors.append(f"Thread {thread_id}: {e}") + + try: + # Reset to original state + set_local_storage_setup_state_exception("") + + # Start multiple threads + threads = [] + for i in range(5): + thread = threading.Thread(target=worker_thread, args=(i,)) + threads.append(thread) + thread.start() + + # Wait for all threads to complete + for thread in threads: + thread.join() + + # Verify no errors occurred + self.assertEqual(len(errors), 0, f"Errors in concurrent access: {errors}") + + # Verify we got results from all threads + self.assertEqual(len(results), 50) # 5 threads * 10 operations each + + # Verify final state is valid + final_value = get_local_storage_setup_state_exception() + if final_value is not None: + self.assertIsInstance(final_value, str) + self.assertTrue(final_value.startswith("Thread-")) + + finally: + # Restore original state + _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state + + def test_local_storage_state_readonly_get_operations(self): + """Test the get operation for readonly state in local storage state""" + from azure.monitor.opentelemetry.exporter.statsbeat._state import ( + get_local_storage_setup_state_readonly, + _LOCAL_STORAGE_SETUP_STATE, + _LOCAL_STORAGE_SETUP_STATE_LOCK + ) + + # Save original state + original_readonly_state = _LOCAL_STORAGE_SETUP_STATE["READONLY"] + + try: + # Test 1: Initial state should be False + self.assertEqual(get_local_storage_setup_state_readonly(), False) + + # Test 2: Set True directly and verify get operation + with _LOCAL_STORAGE_SETUP_STATE_LOCK: + _LOCAL_STORAGE_SETUP_STATE["READONLY"] = True + self.assertEqual(get_local_storage_setup_state_readonly(), True) + + # Test 3: Set False directly and verify get operation + with _LOCAL_STORAGE_SETUP_STATE_LOCK: + _LOCAL_STORAGE_SETUP_STATE["READONLY"] = False + self.assertEqual(get_local_storage_setup_state_readonly(), False) + + # Test 4: Verify get operation doesn't affect other state values + original_exception = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] + with _LOCAL_STORAGE_SETUP_STATE_LOCK: + _LOCAL_STORAGE_SETUP_STATE["READONLY"] = True + + # Get readonly state multiple times + for _ in range(5): + self.assertEqual(get_local_storage_setup_state_readonly(), True) + + # Verify exception state wasn't affected + self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], original_exception) + + finally: + # Restore original state + _LOCAL_STORAGE_SETUP_STATE["READONLY"] = original_readonly_state + + # ======================================================================== + # TRANSMISSION TESTS + # ======================================================================== + def test_transmit_http_error_retryable(self): with mock.patch("azure.monitor.opentelemetry.exporter.export._base._is_retryable_code") as m: m.return_value = True @@ -565,29 +775,152 @@ def test_transmit_request_error(self): result = self._base._transmit(self._envelopes_to_export) self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "false", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "false", - }, - ) - @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._statsbeat.collect_statsbeat_metrics") - def test_transmit_request_error_statsbeat(self, stats_mock): - exporter = BaseExporter(disable_offline_storage=True) - with mock.patch.object(AzureMonitorClient, "track", throw(ServiceRequestError, message="error")): - result = exporter._transmit(self._envelopes_to_export) - stats_mock.assert_called_once() - self.assertEqual(len(_REQUESTS_MAP), 3) - self.assertEqual(_REQUESTS_MAP[_REQ_EXCEPTION_NAME[1]]["ServiceRequestError"], 1) - self.assertIsNotNone(_REQUESTS_MAP[_REQ_DURATION_NAME[1]]) - self.assertEqual(_REQUESTS_MAP["count"], 1) - self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - - def test_transmit_request_exception(self): - with mock.patch.object(AzureMonitorClient, "track", throw(Exception)): + def test_transmission_200(self): + with mock.patch.object(AzureMonitorClient, "track") as post: + post.return_value = TrackResponse( + items_received=1, + items_accepted=1, + errors=[], + ) result = self._base._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) + self.assertEqual(result, ExportResult.SUCCESS) + + def test_transmission_206_retry(self): + exporter = BaseExporter(disable_offline_storage=True) + exporter.storage = mock.Mock() + test_envelope = TelemetryItem(name="testEnvelope", time=datetime.now()) + custom_envelopes_to_export = [ + TelemetryItem(name="Test", time=datetime.now()), + TelemetryItem(name="Test", time=datetime.now()), + test_envelope, + ] + with mock.patch.object(AzureMonitorClient, "track") as post: + post.return_value = TrackResponse( + items_received=3, + items_accepted=1, + errors=[ + TelemetryErrorDetails( + index=0, + status_code=400, + message="should drop", + ), + TelemetryErrorDetails(index=2, status_code=500, message="should retry"), + ], + ) + result = exporter._transmit(custom_envelopes_to_export) + self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) + exporter.storage.put.assert_called_once() + + def test_transmission_206_no_retry(self): + exporter = BaseExporter(disable_offline_storage=True) + exporter.storage = mock.Mock() + test_envelope = TelemetryItem(name="testEnvelope", time=datetime.now()) + custom_envelopes_to_export = [ + TelemetryItem(name="Test", time=datetime.now()), + TelemetryItem(name="Test", time=datetime.now()), + test_envelope, + ] + with mock.patch.object(AzureMonitorClient, "track") as post: + post.return_value = TrackResponse( + items_received=3, + items_accepted=2, + errors=[ + TelemetryErrorDetails( + index=0, + status_code=400, + message="should drop", + ), + ], + ) + result = self._base._transmit(custom_envelopes_to_export) + self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) + exporter.storage.put.assert_not_called() + + def test_transmission_400(self): + with mock.patch("requests.Session.request") as post: + post.return_value = MockResponse(400, "{}") + result = self._base._transmit(self._envelopes_to_export) + self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) + + def test_transmission_402(self): + with mock.patch("requests.Session.request") as post: + post.return_value = MockResponse(402, "{}") + result = self._base._transmit(self._envelopes_to_export) + self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) + + def test_transmission_408(self): + with mock.patch("requests.Session.request") as post: + post.return_value = MockResponse(408, "{}") + result = self._base._transmit(self._envelopes_to_export) + self.assertEqual(result, ExportResult.FAILED_RETRYABLE) + + def test_transmission_429(self): + with mock.patch("requests.Session.request") as post: + post.return_value = MockResponse(429, "{}") + result = self._base._transmit(self._envelopes_to_export) + self.assertEqual(result, ExportResult.FAILED_RETRYABLE) + + def test_transmission_439(self): + with mock.patch("requests.Session.request") as post: + post.return_value = MockResponse(439, "{}") + result = self._base._transmit(self._envelopes_to_export) + self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) + + def test_transmission_500(self): + with mock.patch("requests.Session.request") as post: + post.return_value = MockResponse(500, "{}") + result = self._base._transmit(self._envelopes_to_export) + self.assertEqual(result, ExportResult.FAILED_RETRYABLE) + + def test_transmission_502(self): + with mock.patch("requests.Session.request") as post: + post.return_value = MockResponse(503, "{}") + result = self._base._transmit(self._envelopes_to_export) + self.assertEqual(result, ExportResult.FAILED_RETRYABLE) + + def test_transmission_503(self): + with mock.patch("requests.Session.request") as post: + post.return_value = MockResponse(503, "{}") + result = self._base._transmit(self._envelopes_to_export) + self.assertEqual(result, ExportResult.FAILED_RETRYABLE) + + def test_transmission_504(self): + with mock.patch("requests.Session.request") as post: + post.return_value = MockResponse(504, "{}") + result = self._base._transmit(self._envelopes_to_export) + self.assertEqual(result, ExportResult.FAILED_RETRYABLE) + + def test_transmission_empty(self): + status = self._base._transmit([]) + self.assertEqual(status, ExportResult.SUCCESS) + + # ======================================================================== + # STATSBEAT TESTS + # ======================================================================== + + @mock.patch.dict( + os.environ, + { + "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "false", + "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "false", + }, + ) + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._statsbeat.collect_statsbeat_metrics") + def test_transmit_request_error_statsbeat(self, stats_mock): + exporter = BaseExporter(disable_offline_storage=True) + with mock.patch.object(AzureMonitorClient, "track", throw(ServiceRequestError, message="error")): + result = exporter._transmit(self._envelopes_to_export) + stats_mock.assert_called_once() + self.assertEqual(len(_REQUESTS_MAP), 3) + self.assertEqual(_REQUESTS_MAP[_REQ_EXCEPTION_NAME[1]]["ServiceRequestError"], 1) + self.assertIsNotNone(_REQUESTS_MAP[_REQ_DURATION_NAME[1]]) + self.assertEqual(_REQUESTS_MAP["count"], 1) + self.assertEqual(result, ExportResult.FAILED_RETRYABLE) + + def test_transmit_request_exception(self): + with mock.patch.object(AzureMonitorClient, "track", throw(Exception)): + result = self._base._transmit(self._envelopes_to_export) + self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) @mock.patch.dict( os.environ, @@ -608,16 +941,6 @@ def test_transmit_request_exception_statsbeat(self, stats_mock): self.assertEqual(_REQUESTS_MAP["count"], 1) self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - def test_transmission_200(self): - with mock.patch.object(AzureMonitorClient, "track") as post: - post.return_value = TrackResponse( - items_received=1, - items_accepted=1, - errors=[], - ) - result = self._base._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.SUCCESS) - @mock.patch.dict( os.environ, { @@ -641,32 +964,6 @@ def test_statsbeat_200(self, stats_mock): self.assertEqual(_REQUESTS_MAP["count"], 1) self.assertEqual(result, ExportResult.SUCCESS) - def test_transmission_206_retry(self): - exporter = BaseExporter(disable_offline_storage=True) - exporter.storage = mock.Mock() - test_envelope = TelemetryItem(name="testEnvelope", time=datetime.now()) - custom_envelopes_to_export = [ - TelemetryItem(name="Test", time=datetime.now()), - TelemetryItem(name="Test", time=datetime.now()), - test_envelope, - ] - with mock.patch.object(AzureMonitorClient, "track") as post: - post.return_value = TrackResponse( - items_received=3, - items_accepted=1, - errors=[ - TelemetryErrorDetails( - index=0, - status_code=400, - message="should drop", - ), - TelemetryErrorDetails(index=2, status_code=500, message="should retry"), - ], - ) - result = exporter._transmit(custom_envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - exporter.storage.put.assert_called_once() - @mock.patch.dict( os.environ, { @@ -705,31 +1002,6 @@ def test_statsbeat_206_retry(self, stats_mock): self.assertIsNotNone(_REQUESTS_MAP[_REQ_DURATION_NAME[1]]) self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - def test_transmission_206_no_retry(self): - exporter = BaseExporter(disable_offline_storage=True) - exporter.storage = mock.Mock() - test_envelope = TelemetryItem(name="testEnvelope", time=datetime.now()) - custom_envelopes_to_export = [ - TelemetryItem(name="Test", time=datetime.now()), - TelemetryItem(name="Test", time=datetime.now()), - test_envelope, - ] - with mock.patch.object(AzureMonitorClient, "track") as post: - post.return_value = TrackResponse( - items_received=3, - items_accepted=2, - errors=[ - TelemetryErrorDetails( - index=0, - status_code=400, - message="should drop", - ), - ], - ) - result = self._base._transmit(custom_envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - exporter.storage.put.assert_not_called() - @mock.patch.dict( os.environ, { @@ -765,12 +1037,6 @@ def test_statsbeat_206_no_retry(self, stats_mock): self.assertIsNotNone(_REQUESTS_MAP[_REQ_DURATION_NAME[1]]) self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - def test_transmission_400(self): - with mock.patch("requests.Session.request") as post: - post.return_value = MockResponse(400, "{}") - result = self._base._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - @mock.patch.dict( os.environ, { @@ -792,12 +1058,6 @@ def test_statsbeat_400(self, stats_mock, stats_shutdown_mock): self.assertEqual(_REQUESTS_MAP["count"], 1) self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - def test_transmission_402(self): - with mock.patch("requests.Session.request") as post: - post.return_value = MockResponse(402, "{}") - result = self._base._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - @mock.patch.dict( os.environ, { @@ -817,12 +1077,6 @@ def test_statsbeat_402(self, stats_mock): self.assertEqual(_REQUESTS_MAP["count"], 1) self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - def test_transmission_408(self): - with mock.patch("requests.Session.request") as post: - post.return_value = MockResponse(408, "{}") - result = self._base._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - @mock.patch.dict( os.environ, { @@ -842,12 +1096,6 @@ def test_statsbeat_408(self, stats_mock): self.assertEqual(_REQUESTS_MAP["count"], 1) self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - def test_transmission_429(self): - with mock.patch("requests.Session.request") as post: - post.return_value = MockResponse(429, "{}") - result = self._base._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - @mock.patch.dict( os.environ, { @@ -867,12 +1115,6 @@ def test_statsbeat_429(self, stats_mock): self.assertEqual(_REQUESTS_MAP["count"], 1) self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - def test_transmission_439(self): - with mock.patch("requests.Session.request") as post: - post.return_value = MockResponse(439, "{}") - result = self._base._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - @mock.patch.dict( os.environ, { @@ -892,12 +1134,6 @@ def test_statsbeat_439(self, stats_mock): self.assertEqual(_REQUESTS_MAP["count"], 1) self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - def test_transmission_500(self): - with mock.patch("requests.Session.request") as post: - post.return_value = MockResponse(500, "{}") - result = self._base._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - @mock.patch.dict( os.environ, { @@ -917,12 +1153,6 @@ def test_statsbeat_500(self, stats_mock): self.assertEqual(_REQUESTS_MAP["count"], 1) self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - def test_transmission_502(self): - with mock.patch("requests.Session.request") as post: - post.return_value = MockResponse(503, "{}") - result = self._base._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - @mock.patch.dict( os.environ, { @@ -942,12 +1172,6 @@ def test_statsbeat_502(self, stats_mock): self.assertEqual(_REQUESTS_MAP["count"], 1) self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - def test_transmission_503(self): - with mock.patch("requests.Session.request") as post: - post.return_value = MockResponse(503, "{}") - result = self._base._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - @mock.patch.dict( os.environ, { @@ -964,12 +1188,6 @@ def test_statsbeat_503(self): self.assertEqual(_REQUESTS_MAP["count"], 1) self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - def test_transmission_504(self): - with mock.patch("requests.Session.request") as post: - post.return_value = MockResponse(504, "{}") - result = self._base._transmit(self._envelopes_to_export) - self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - @mock.patch.dict( os.environ, { @@ -989,9 +1207,9 @@ def test_statsbeat_504(self, stats_mock): self.assertEqual(_REQUESTS_MAP["count"], 1) self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - def test_transmission_empty(self): - status = self._base._transmit([]) - self.assertEqual(status, ExportResult.SUCCESS) + # ======================================================================== + # AUTHENTICATION AND CREDENTIAL TESTS + # ======================================================================== @mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_authentication_credential") @mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_auth_policy") @@ -1040,7 +1258,7 @@ def test_credential_env_var_and_arg(self, mock_add_credential_policy, mock_get_a @mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_authentication_credential") def test_statsbeat_no_credential(self, mock_get_authentication_credential): mock_get_authentication_credential.return_value = "TEST_CREDENTIAL_ENV_VAR" - statsbeat_exporter = _StatsBeatExporter() + statsbeat_exporter = AzureMonitorMetricExporter(is_sdkstats=True) self.assertIsNone(statsbeat_exporter._credential) mock_get_authentication_credential.assert_not_called() @@ -1066,32 +1284,6 @@ def invalid_get_token(): ValueError, _get_auth_policy, credential=InvalidTestCredential(), default_auth_policy=TEST_AUTH_POLICY ) - @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._track_dropped_items") - @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._track_dropped_items") - def test_handle_transmit_from_storage_success_result(self, mock_track_dropped1, mock_track_dropped2): - """Test that when storage.put() returns StorageExportResult.LOCAL_FILE_BLOB_SUCCESS, - the method continues without any special handling.""" - exporter = BaseExporter(disable_offline_storage=False) - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock storage.put() to return success - exporter.storage = mock.Mock() - exporter.storage.put.return_value = StorageExportResult.LOCAL_FILE_BLOB_SUCCESS - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - serialized_envelopes = [envelope.as_dict() for envelope in test_envelopes] - exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify storage.put was called with the serialized envelopes - exporter.storage.put.assert_called_once_with(serialized_envelopes) - # Verify that no dropped items were tracked (since it was a success) - mock_track_dropped1.assert_not_called() - mock_track_dropped2.assert_not_called() - # Verify that the customer sdkstats wasn't invoked - mock_customer_sdkstats.assert_not_called() - def test_get_auth_policy_audience(self): class TestCredential: def get_token(): @@ -1211,3577 +1403,170 @@ def test_get_authentication_credential_error(self, mock_managed_identity): self.assertIsNone(result) mock_managed_identity.assert_called_once_with(client_id="TEST_CLIENT_ID") - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_constructor_customer_sdkstats_storage_integration(self): - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.collect_customer_sdkstats") as mock_collect: - mock_customer_sdkstats = mock.Mock() - - def mock_collect_side_effect(exporter): - setattr(exporter, '_customer_sdkstats_metrics', mock_customer_sdkstats) - if hasattr(exporter, 'storage') and exporter.storage: - setattr(exporter.storage, '_customer_sdkstats_metrics', mock_customer_sdkstats) - - mock_collect.side_effect = mock_collect_side_effect - - exporter = BaseExporter( - connection_string="InstrumentationKey=363331ca-f431-4119-bdcd-31a75920f958;IngestionEndpoint=https://eastus-8.in.applicationinsights.azure.com/", - disable_offline_storage=False, - ) - - mock_collect.assert_called_once_with(exporter) - - self.assertEqual(exporter._customer_sdkstats_metrics, mock_customer_sdkstats) - - self.assertIsNotNone(exporter.storage) - self.assertEqual(exporter.storage._customer_sdkstats_metrics, mock_customer_sdkstats) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_constructor_customer_sdkstats_no_storage(self): - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.collect_customer_sdkstats") as mock_collect: - mock_customer_sdkstats = mock.Mock() - - def mock_collect_side_effect(exporter): - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - if hasattr(exporter, 'storage') and exporter.storage: - exporter.storage._customer_sdkstats_metrics = mock_customer_sdkstats - - mock_collect.side_effect = mock_collect_side_effect - - exporter = BaseExporter( - connection_string="InstrumentationKey=363331ca-f431-4119-bdcd-31a75920f958;IngestionEndpoint=https://eastus-8.in.applicationinsights.azure.com/", - disable_offline_storage=True, - ) - - mock_collect.assert_called_once_with(exporter) - - self.assertEqual(exporter._customer_sdkstats_metrics, mock_customer_sdkstats) - - self.assertIsNone(exporter.storage) + # Custom Breeze Message Handling Tests + # These tests verify that custom error messages from Azure Monitor service (Breeze) + # are properly preserved and passed through the error handling chain. - def test_customer_sdkstats_shutdown_state(self): - """Test that customer sdkstats shutdown state works correctly""" - from azure.monitor.opentelemetry.exporter.statsbeat._state import ( - get_customer_sdkstats_shutdown, - _CUSTOMER_SDKSTATS_STATE, - _CUSTOMER_SDKSTATS_STATE_LOCK - ) - - # Initially should not be shutdown (reset in setUp) - self.assertFalse(get_customer_sdkstats_shutdown()) - - # Directly set shutdown state (simulating what shutdown function should do) - with _CUSTOMER_SDKSTATS_STATE_LOCK: - _CUSTOMER_SDKSTATS_STATE["SHUTDOWN"] = True - - # Should now be shutdown - self.assertTrue(get_customer_sdkstats_shutdown()) + # ======================================================================== + # UTILITY AND HELPER FUNCTION TESTS + # ======================================================================== - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "false", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_should_collect_customer_sdkstats_with_shutdown(self): - """Test that _should_collect_customer_sdkstats respects shutdown state""" - from azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats import ( - _CUSTOMER_SDKSTATS_STATE, - _CUSTOMER_SDKSTATS_STATE_LOCK - ) - - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789012", - disable_offline_storage=True - ) - - # Should collect when not shutdown (verified by environment variables) - self.assertTrue(exporter._should_collect_customer_sdkstats()) + def test_determine_client_retry_code_telemetry_error_details_with_custom_message(self): + """Test that TelemetryErrorDetails with custom message preserves the message for specific status codes.""" + exporter = BaseExporter(disable_offline_storage=True) - # Directly set shutdown state (simulating what shutdown function should do) - with _CUSTOMER_SDKSTATS_STATE_LOCK: - _CUSTOMER_SDKSTATS_STATE["SHUTDOWN"] = True + # Test various specific status codes with custom messages + test_cases = [ + (401, "Authentication failed. Please check your instrumentation key."), + (403, "Forbidden access. Verify your permissions for this resource."), + (408, "Request timeout. The service took too long to respond."), + (429, "Rate limit exceeded for instrumentation key. Current rate: 1000 req/min, limit: 500 req/min."), + (500, "Internal server error. Please try again later."), + (502, "Bad gateway. The upstream server is unavailable."), + (503, "Service unavailable. The monitoring service is temporarily down."), + (504, "Gateway timeout. The request timed out while waiting for the upstream server."), + ] - # Should not collect when shutdown - self.assertFalse(exporter._should_collect_customer_sdkstats()) + for status_code, custom_message in test_cases: + with self.subTest(status_code=status_code): + error = TelemetryErrorDetails( + index=0, + status_code=status_code, + message=custom_message + ) + + retry_code, message = _determine_client_retry_code(error) + self.assertEqual(retry_code, status_code) + self.assertEqual(message, custom_message) - def test_customer_sdkstats_shutdown_on_invalid_code(self): - """Test that customer sdkstats shutdown is called and state updated on invalid response codes""" - # Import needed components for verification - from azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats import ( - _CUSTOMER_SDKSTATS_STATE, - _CUSTOMER_SDKSTATS_STATE_LOCK, - CustomerSdkStatsMetrics - ) - - # Set up test environment - exporter = BaseExporter() - envelope = TelemetryItem(name="test", time=datetime.now()) - - # Set up mocks for the actual implementations - with _CUSTOMER_SDKSTATS_STATE_LOCK: - _CUSTOMER_SDKSTATS_STATE["SHUTDOWN"] = False - - # Set up a meter provider mock to ensure we have something to shutdown - mock_meter_provider = mock.MagicMock() - - # Create a mock instance for CustomerSdkStatsMetrics to be used in the global variable - mock_instance = mock.MagicMock() - mock_instance._customer_sdkstats_meter_provider = mock_meter_provider + def test_determine_client_retry_code_telemetry_error_details_without_message(self): + """Test that TelemetryErrorDetails without message returns _UNKNOWN for specific status codes.""" + exporter = BaseExporter(disable_offline_storage=True) - # Set _CUSTOMER_SDKSTATS_METRICS to our mock instance - import sys - customer_sdkstats_module = sys.modules['azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats'] - original_metrics = getattr(customer_sdkstats_module, '_CUSTOMER_SDKSTATS_METRICS', None) - setattr(customer_sdkstats_module, '_CUSTOMER_SDKSTATS_METRICS', mock_instance) + status_codes = [401, 403, 408, 429, 500, 502, 503, 504] - try: - # Execute the test scenario - with mock.patch("requests.Session.request") as post: - post.return_value = MockResponse(400, "Invalid request") - result = exporter._transmit([envelope]) - - # Verify the result is as expected - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - - # Verify the meter provider's shutdown was called - mock_meter_provider.shutdown.assert_called_once() + for status_code in status_codes: + with self.subTest(status_code=status_code): + error = TelemetryErrorDetails( + index=0, + status_code=status_code, + message=None + ) - # Verify that the state was properly updated to indicate shutdown happened - self.assertTrue(_CUSTOMER_SDKSTATS_STATE["SHUTDOWN"], - "The SHUTDOWN state should be set to True after invalid response code") - finally: - # Restore the original _CUSTOMER_SDKSTATS_METRICS - setattr(customer_sdkstats_module, '_CUSTOMER_SDKSTATS_METRICS', original_metrics) + retry_code, message = _determine_client_retry_code(error) + self.assertEqual(retry_code, status_code) + self.assertEqual(message, _UNKNOWN) - def test_customer_sdkstats_shutdown_on_failure_threshold(self): - """Test that customer sdkstats shutdown function properly updates the shutdown state""" - # This test verifies that the shutdown_customer_sdkstats_metrics function - # properly updates the SHUTDOWN state when called - - # Import needed components for verification - from azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats import ( - shutdown_customer_sdkstats_metrics, - _CUSTOMER_SDKSTATS_STATE, - _CUSTOMER_SDKSTATS_STATE_LOCK, - CustomerSdkStatsMetrics - ) - - # Set up a meter provider mock to ensure we have something to shutdown - mock_meter_provider = mock.MagicMock() - - # Create a mock instance for CustomerSdkStatsMetrics to be used in the global variable - mock_instance = mock.MagicMock() - mock_instance._customer_sdkstats_meter_provider = mock_meter_provider - - # Set _CUSTOMER_SDKSTATS_METRICS to our mock instance - import sys - customer_sdkstats_module = sys.modules['azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats'] - original_metrics = getattr(customer_sdkstats_module, '_CUSTOMER_SDKSTATS_METRICS', None) - setattr(customer_sdkstats_module, '_CUSTOMER_SDKSTATS_METRICS', mock_instance) + def test_determine_client_retry_code_telemetry_error_details_empty_message(self): + """Test that TelemetryErrorDetails with empty message returns _UNKNOWN for specific status codes.""" + exporter = BaseExporter(disable_offline_storage=True) - try: - # Make sure state starts with SHUTDOWN as False - with _CUSTOMER_SDKSTATS_STATE_LOCK: - _CUSTOMER_SDKSTATS_STATE["SHUTDOWN"] = False - - # Call the actual shutdown function directly (no mocking) - shutdown_customer_sdkstats_metrics() - - # Verify the meter provider's shutdown was called - mock_meter_provider.shutdown.assert_called_once() - - # Verify that the state was properly updated by the function - self.assertTrue(_CUSTOMER_SDKSTATS_STATE["SHUTDOWN"], - "The SHUTDOWN state should be set to True after shutdown_customer_sdkstats_metrics is called") - finally: - # Restore the original _CUSTOMER_SDKSTATS_METRICS - setattr(customer_sdkstats_module, '_CUSTOMER_SDKSTATS_METRICS', original_metrics) + status_codes = [401, 403, 408, 429, 500, 502, 503, 504] - # Note: The actual integration point exists in _base.py where both shutdown - # functions are called together during failure threshold + for status_code in status_codes: + with self.subTest(status_code=status_code): + error = TelemetryErrorDetails( + index=0, + status_code=status_code, + message="" + ) + + retry_code, message = _determine_client_retry_code(error) + self.assertEqual(retry_code, status_code) + self.assertEqual(message, _UNKNOWN) - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "false", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_customer_sdkstats_no_collection_after_shutdown(self): - """Test that customer sdkstats is not collected after shutdown""" - from azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats import ( - _CUSTOMER_SDKSTATS_STATE, - _CUSTOMER_SDKSTATS_STATE_LOCK - ) + def test_determine_client_retry_code_http_response_error_with_custom_message(self): + """Test that HttpResponseError with custom message preserves the message for specific status codes.""" + exporter = BaseExporter(disable_offline_storage=True) - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.collect_customer_sdkstats") as mock_collect: - # First exporter should trigger collection (if not already shutdown) - exporter1 = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789012", - disable_offline_storage=True - ) - initial_should_collect = exporter1._should_collect_customer_sdkstats() - - # Directly set shutdown state (simulating what shutdown function should do) - with _CUSTOMER_SDKSTATS_STATE_LOCK: - _CUSTOMER_SDKSTATS_STATE["SHUTDOWN"] = True - - # Second exporter should not trigger collection - exporter2 = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789012", - disable_offline_storage=True - ) - self.assertFalse(exporter2._should_collect_customer_sdkstats()) - - def test_handle_transmit_from_storage_success_triggers_transmit(self): - exporter = BaseExporter(disable_offline_storage=False) + test_cases = [ + (429, "Rate limit exceeded. Please reduce your request rate."), + (500, "Internal server error occurred during telemetry processing."), + (503, "Service temporarily unavailable due to high load."), + ] - with mock.patch.object(exporter, '_transmit_from_storage') as mock_transmit_from_storage: - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - exporter._handle_transmit_from_storage(test_envelopes, ExportResult.SUCCESS) - - mock_transmit_from_storage.assert_called_once() + for status_code, custom_message in test_cases: + with self.subTest(status_code=status_code): + error = HttpResponseError() + error.status_code = status_code + error.message = custom_message + + retry_code, message = _determine_client_retry_code(error) + self.assertEqual(retry_code, status_code) + self.assertEqual(message, custom_message) - def test_handle_transmit_from_storage_no_storage(self): + def test_determine_client_retry_code_generic_error_with_message_attribute(self): + """Test that generic errors with message attribute preserve the message for specific status codes.""" exporter = BaseExporter(disable_offline_storage=True) - self.assertIsNone(exporter.storage) - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] + test_cases = [ + (401, "Custom auth error from service"), + (429, "Custom rate limit message"), + (500, "Custom server error message"), + ] - exporter._handle_transmit_from_storage(test_envelopes, ExportResult.SUCCESS) - exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) + for status_code, custom_message in test_cases: + with self.subTest(status_code=status_code): + error = mock.Mock() + error.status_code = status_code + error.message = custom_message + + retry_code, message = _determine_client_retry_code(error) + self.assertEqual(retry_code, status_code) + self.assertEqual(message, custom_message) - def test_transmit_from_storage_no_storage(self): + def test_determine_client_retry_code_non_specific_status_codes(self): + """Test that non-specific status codes are handled with CLIENT_EXCEPTION.""" exporter = BaseExporter(disable_offline_storage=True) - self.assertIsNone(exporter.storage) + # Test non-specific status codes + non_specific_codes = [400, 404, 410, 413] - exporter._transmit_from_storage() + for status_code in non_specific_codes: + with self.subTest(status_code=status_code): + error = mock.Mock() + error.status_code = status_code + error.message = f"Error message for {status_code}" + + retry_code, message = _determine_client_retry_code(error) + self.assertEqual(retry_code, RetryCode.CLIENT_EXCEPTION) + self.assertEqual(message, str(error)) - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.statsbeat._utils._track_dropped_items') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - def test_handle_transmit_from_storage_client_storage_disabled_tracked(self, mock_track_dropped_from_storage, mock_track_dropped): - """Test that _handle_transmit_from_storage tracks CLIENT_STORAGE_DISABLED when storage.put() returns CLIENT_STORAGE_DISABLED""" - exporter = BaseExporter(disable_offline_storage=False) - - # Setup customer sdkstats - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock the storage to return CLIENT_STORAGE_DISABLED - exporter.storage = mock.Mock() - exporter.storage.put.return_value = StorageExportResult.CLIENT_STORAGE_DISABLED + def test_determine_client_retry_code_http_status_codes(self): + exporter = BaseExporter(disable_offline_storage=True) - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] + status_codes = [401, 403, 408, 429, 500, 502, 503, 504] - # Set up side_effect for _track_dropped_items_from_storage - def side_effect(customer_sdkstats, result_from_storage_put, envelopes): - from azure.monitor.opentelemetry.exporter.statsbeat._utils import _track_dropped_items_from_storage - # Call the real function which will use our mocked _track_dropped_items - _track_dropped_items_from_storage(customer_sdkstats, result_from_storage_put, envelopes) + for status_code in status_codes: + # Create mock without message attribute to test _UNKNOWN fallback + error = mock.Mock(spec=['status_code']) + error.status_code = status_code - mock_track_dropped_from_storage.side_effect = side_effect + retry_code, message = _determine_client_retry_code(error) + self.assertEqual(retry_code, status_code) + self.assertEqual(message, _UNKNOWN) + + def test_determine_client_retry_code_service_request_error(self): + exporter = BaseExporter(disable_offline_storage=True) - # Call _handle_transmit_from_storage with FAILED_RETRYABLE - result = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) + error = ServiceRequestError("Connection failed") - # Verify storage.put was called - exporter.storage.put.assert_called_once() + retry_code, message = _determine_client_retry_code(error) + self.assertEqual(retry_code, RetryCode.CLIENT_EXCEPTION) + self.assertEqual(message, "Connection failed") + + def test_determine_client_retry_code_service_request_error_with_message(self): + exporter = BaseExporter(disable_offline_storage=True) - # Verify that _track_dropped_items_from_storage was called with CLIENT_STORAGE_DISABLED - mock_track_dropped_from_storage.assert_called_once_with( - mock_customer_sdkstats, StorageExportResult.CLIENT_STORAGE_DISABLED, test_envelopes - ) + error = ServiceRequestError("Network error") + error.message = "Specific network error" - # Verify that _track_dropped_items was called with CLIENT_STORAGE_DISABLED - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_STORAGE_DISABLED - ) + retry_code, message = _determine_client_retry_code(error) + self.assertEqual(retry_code, RetryCode.CLIENT_EXCEPTION) + self.assertEqual(message, "Specific network error") - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.statsbeat._utils._track_dropped_items') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - def test_handle_transmit_from_storage_client_readonly_tracked(self, mock_track_dropped_from_storage, mock_track_dropped): - """Test that _handle_transmit_from_storage tracks CLIENT_READONLY when storage.put() returns CLIENT_READONLY""" - exporter = BaseExporter(disable_offline_storage=False) - - # Setup customer sdkstats - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock the storage to return CLIENT_READONLY - exporter.storage = mock.Mock() - exporter.storage.put.return_value = StorageExportResult.CLIENT_READONLY - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Set up side_effect for _track_dropped_items_from_storage - def side_effect(customer_sdkstats, result_from_storage_put, envelopes): - from azure.monitor.opentelemetry.exporter.statsbeat._utils import _track_dropped_items_from_storage - # Call the real function which will use our mocked _track_dropped_items - _track_dropped_items_from_storage(customer_sdkstats, result_from_storage_put, envelopes) - - mock_track_dropped_from_storage.side_effect = side_effect - - # Save the original readonly state - original_readonly_state = _LOCAL_STORAGE_SETUP_STATE["READONLY"] - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = True - - try: - # Call _handle_transmit_from_storage with FAILED_RETRYABLE - result = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify storage.put was called - exporter.storage.put.assert_called_once() - - # Verify that _track_dropped_items_from_storage was called with the right arguments - mock_track_dropped_from_storage.assert_called_once_with( - mock_customer_sdkstats, StorageExportResult.CLIENT_READONLY, test_envelopes - ) - - # Verify that _track_dropped_items was called with CLIENT_READONLY - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_READONLY - ) - - # Verify _LOCAL_STORAGE_SETUP_STATE READONLY remains True (once set, it stays True) - self.assertTrue(_LOCAL_STORAGE_SETUP_STATE["READONLY"]) - - # Verify the method returns None as expected - self.assertIsNone(result) - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = original_readonly_state - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.statsbeat._utils._track_dropped_items') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - def test_handle_transmit_from_storage_client_persistence_capacity_tracked(self, mock_track_dropped_from_storage, mock_track_dropped): - """Test that _handle_transmit_from_storage tracks CLIENT_PERSISTENCE_CAPACITY when storage.put() returns CLIENT_PERSISTENCE_CAPACITY_REACHED""" - exporter = BaseExporter(disable_offline_storage=False) - - # Setup customer sdkstats - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock the storage to return CLIENT_PERSISTENCE_CAPACITY_REACHED - exporter.storage = mock.Mock() - exporter.storage.put.return_value = StorageExportResult.CLIENT_PERSISTENCE_CAPACITY_REACHED - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Set up side_effect for _track_dropped_items_from_storage - def side_effect(customer_sdkstats, result_from_storage_put, envelopes): - from azure.monitor.opentelemetry.exporter.statsbeat._utils import _track_dropped_items_from_storage - # Call the real function which will use our mocked _track_dropped_items - _track_dropped_items_from_storage(customer_sdkstats, result_from_storage_put, envelopes) - - mock_track_dropped_from_storage.side_effect = side_effect - - # Call _handle_transmit_from_storage with FAILED_RETRYABLE - result = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify storage.put was called - exporter.storage.put.assert_called_once() - - # Verify that _track_dropped_items_from_storage was called with the right arguments - mock_track_dropped_from_storage.assert_called_once_with( - mock_customer_sdkstats, StorageExportResult.CLIENT_PERSISTENCE_CAPACITY_REACHED, test_envelopes - ) - - # Verify that _track_dropped_items was called with CLIENT_PERSISTENCE_CAPACITY - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_PERSISTENCE_CAPACITY - ) - - # Verify the method returns None as expected - self.assertIsNone(result) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_handle_transmit_from_storage_client_exception_tracked(self, mock_track_dropped, mock_track_dropped_from_storage): - """Test that _handle_transmit_from_storage tracks CLIENT_EXCEPTION when storage.put() returns an error string and updates _LOCAL_STORAGE_SETUP_STATE""" - exporter = BaseExporter(disable_offline_storage=False) - - # Setup customer sdkstats - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock the storage to return an error string (not one of the enum values) - error_message = "Storage write failed: Permission denied" - exporter.storage = mock.Mock() - exporter.storage.put.return_value = error_message - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Set initial exception state - original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = error_message - - # Set up side_effect for _track_dropped_items_from_storage - def side_effect(customer_sdkstats, result_from_storage_put, envelopes): - from azure.monitor.opentelemetry.exporter.export._base import _track_dropped_items - if isinstance(result_from_storage_put, str): - _track_dropped_items(customer_sdkstats, envelopes, DropCode.CLIENT_EXCEPTION, result_from_storage_put) - - mock_track_dropped_from_storage.side_effect = side_effect - - try: - # Directly call storage.put and track_dropped_items_from_storage - envelopes_to_store = [x.as_dict() for x in test_envelopes] - result_from_storage = exporter.storage.put(envelopes_to_store) - - # Call _track_dropped_items_from_storage directly - mock_track_dropped_from_storage(mock_customer_sdkstats, result_from_storage, test_envelopes) - - # Verify storage.put was called - exporter.storage.put.assert_called_once() - - # Verify that _track_dropped_items_from_storage was called with error message - mock_track_dropped_from_storage.assert_called_once_with( - mock_customer_sdkstats, error_message, test_envelopes - ) - - # Verify that _track_dropped_items was called with CLIENT_EXCEPTION and error message - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_EXCEPTION, error_message - ) - - # Verify _LOCAL_STORAGE_SETUP_STATE remains unchanged during execution - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], error_message) - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_handle_transmit_from_storage_no_storage_client_storage_disabled_tracked(self, mock_track_dropped): - """Test that _handle_transmit_from_storage tracks CLIENT_STORAGE_DISABLED when storage is disabled""" - exporter = BaseExporter(disable_offline_storage=True) - - # Setup customer sdkstats - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Verify storage is None - self.assertIsNone(exporter.storage) - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Call _handle_transmit_from_storage with FAILED_RETRYABLE - result = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify that _track_dropped_items was called with CLIENT_STORAGE_DISABLED - mock_track_dropped.assert_called_once_with(mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_STORAGE_DISABLED) - - # Verify no return value when storage is disabled - self.assertIsNone(result) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_handle_transmit_from_storage_success_triggers_transmit_from_storage(self, ): - """Test that _handle_transmit_from_storage calls _transmit_from_storage on SUCCESS""" - exporter = BaseExporter(disable_offline_storage=False) - - # Setup customer sdkstats - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock storage and _transmit_from_storage - exporter.storage = mock.Mock() - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - with mock.patch.object(exporter, '_transmit_from_storage') as mock_transmit_from_storage: - # Call _handle_transmit_from_storage with SUCCESS - result = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.SUCCESS) - - # Verify _transmit_from_storage was called - mock_transmit_from_storage.assert_called_once() - - # Verify storage.put was not called for SUCCESS - exporter.storage.put.assert_not_called() - - # Verify no return value for SUCCESS - self.assertIsNone(result) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_partial_success_206_client_exception_tracking(self, mock_track_dropped, mock_track_dropped_from_storage): - """Test that both _track_dropped_items_from_storage and _track_dropped_items are called correctly - when there's a 206 Partial Success with CLIENT_EXCEPTION scenario.""" - # Set up side effect to call the real function but use our mock for _track_dropped_items - def side_effect(statsbeat, result_from_storage, telemetry): - from azure.monitor.opentelemetry.exporter.statsbeat._utils import _track_dropped_items - if isinstance(result_from_storage, str): - _track_dropped_items(statsbeat, telemetry, DropCode.CLIENT_EXCEPTION, result_from_storage) - mock_track_dropped_from_storage.side_effect = side_effect - - # Create base exporter - exporter = BaseExporter() - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Enable storage - exporter.storage = mock.Mock() - - # Create test envelopes for first batch that will be "accepted" - accepted_envelopes = [TelemetryItem(name="accepted", time=datetime.now())] - - # Create test envelopes for second batch that will be "retried" due to 206 - resend_envelopes = [TelemetryItem(name="retried", time=datetime.now())] - - # Mock the storage.put method to return a string error (simulating CLIENT_EXCEPTION) - error_message = "Test error message for client exception" - exporter.storage.put.return_value = error_message - - # Mock transmit method to return partial success (206) and trigger track_dropped_items_from_storage - with mock.patch.object(AzureMonitorClient, "track") as mock_track: - # Setup mock for 206 response with one retryable error - mock_track.return_value = TrackResponse( - items_received=2, - items_accepted=1, - errors=[ - TelemetryErrorDetails(index=1, status_code=500, message="should retry"), - ], - ) - - # Call _transmit to trigger the code path - result = exporter._transmit(accepted_envelopes + resend_envelopes) - - # Verify storage.put was called - exporter.storage.put.assert_called_once() - - # Verify _track_dropped_items_from_storage was called with the correct parameters - mock_track_dropped_from_storage.assert_called_once() - dropped_args = mock_track_dropped_from_storage.call_args[0] - self.assertEqual(dropped_args[0], mock_customer_sdkstats) - self.assertEqual(dropped_args[1], error_message) # The error message from storage.put - - # Verify _track_dropped_items was called with CLIENT_EXCEPTION from our side_effect - mock_track_dropped.assert_called_once() - dropped_items_args = mock_track_dropped.call_args[0] - self.assertEqual(dropped_items_args[0], mock_customer_sdkstats) - self.assertEqual(dropped_items_args[2], DropCode.CLIENT_EXCEPTION) - self.assertEqual(dropped_items_args[3], error_message) - - # Verify result is FAILED_NOT_RETRYABLE as we already tried to store - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_LOCAL_STORAGE_SETUP_STATE_exception_isolation_with_errno(self, mock_track_dropped, mock_track_dropped_from_storage): - """Test that errno-based exceptions are properly isolated and don't affect readonly state""" - # Save original state - original_readonly_state = _LOCAL_STORAGE_SETUP_STATE["READONLY"] - original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - - try: - # Test with both states - readonly True, exception state empty (normal post-reset state) - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = True - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = "" - - exporter = BaseExporter(disable_offline_storage=False) - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock storage.put() to return error string - exporter.storage = mock.Mock() - exporter.storage.put.return_value = "Storage error occurred" - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Instead of actually mocking the call, we'll side_effect to call the real function - # but with our mocked _track_dropped_items - def side_effect(customer_sdkstats, result_from_storage_put, envelopes): - from azure.monitor.opentelemetry.exporter.export._base import _track_dropped_items - if isinstance(result_from_storage_put, str): - _track_dropped_items(customer_sdkstats, envelopes, DropCode.CLIENT_EXCEPTION, result_from_storage_put) - - mock_track_dropped_from_storage.side_effect = side_effect - - # Directly call storage.put and track_dropped_items_from_storage instead of _handle_transmit_from_storage - envelopes_to_store = [x.as_dict() for x in test_envelopes] - result_from_storage = exporter.storage.put(envelopes_to_store) - - # Call _track_dropped_items_from_storage directly - mock_track_dropped_from_storage(mock_customer_sdkstats, result_from_storage, test_envelopes) - - # Verify that _track_dropped_items_from_storage was called with the right arguments - mock_track_dropped_from_storage.assert_called_once_with( - mock_customer_sdkstats, "Storage error occurred", test_envelopes - ) - - # Verify that _track_dropped_items was called with CLIENT_EXCEPTION and the error message - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_EXCEPTION, "Storage error occurred" - ) - - # Verify readonly remains True, exception state remains empty - self.assertTrue(_LOCAL_STORAGE_SETUP_STATE["READONLY"]) - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], "") - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = original_readonly_state - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state - - # ===== Comprehensive _handle_transmit_from_storage Error Scenario Tests ===== - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_handle_transmit_from_storage_localfilestorage_readonly_simulation(self, mock_track_dropped, mock_track_dropped_from_storage): - """Test LocalFileStorage(read-only filesystem) simulation during _check_and_set_folder_permissions""" - # Set up side effect to call the real function but use our mock for _track_dropped_items - def side_effect(statsbeat, result_from_storage_put, telemetry): - from azure.monitor.opentelemetry.exporter.export._base import _track_dropped_items - if result_from_storage_put == StorageExportResult.CLIENT_READONLY: - _track_dropped_items(statsbeat, telemetry, DropCode.CLIENT_READONLY) - mock_track_dropped_from_storage.side_effect = side_effect - """Test LocalFileStorage(read-only filesystem) simulation during _check_and_set_folder_permissions""" - # Save original state - original_readonly_state = _LOCAL_STORAGE_SETUP_STATE["READONLY"] - original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - - try: - # Simulate (Read-only file system) during folder permissions check - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = True - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = "" - - exporter = BaseExporter(disable_offline_storage=False) - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock storage to return CLIENT_READONLY - exporter.storage = mock.Mock() - exporter.storage.put.return_value = StorageExportResult.CLIENT_READONLY - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Call _handle_transmit_from_storage with FAILED_RETRYABLE - result = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify storage.put was called - exporter.storage.put.assert_called_once() - - # Verify that _track_dropped_items was called with CLIENT_READONLY - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_READONLY - ) - - # Verify readonly state remains True (once set, it stays True) - self.assertTrue(_LOCAL_STORAGE_SETUP_STATE["READONLY"]) - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], "") - - self.assertIsNone(result) - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = original_readonly_state - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_partial_success_206_client_exception_tracking(self, mock_track_dropped, mock_track_dropped_from_storage): - """Test that both _track_dropped_items_from_storage and _track_dropped_items are called correctly - when there's a 206 Partial Success with CLIENT_EXCEPTION scenario.""" - # Set up side effect to call the real function but use our mock for _track_dropped_items - def side_effect(statsbeat, result_from_storage, telemetry): - from azure.monitor.opentelemetry.exporter.export._base import _track_dropped_items - if isinstance(result_from_storage, str): - _track_dropped_items(statsbeat, telemetry, DropCode.CLIENT_EXCEPTION, result_from_storage) - mock_track_dropped_from_storage.side_effect = side_effect - - # Create base exporter - exporter = BaseExporter() - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Enable storage - exporter.storage = mock.Mock() - - # Create test envelopes for first batch that will be "accepted" - accepted_envelopes = [TelemetryItem(name="accepted", time=datetime.now())] - - # Create test envelopes for second batch that will be "retried" due to 206 - resend_envelopes = [TelemetryItem(name="retried", time=datetime.now())] - - # Mock the storage.put method to return a string error (simulating CLIENT_EXCEPTION) - error_message = "Test error message for client exception" - exporter.storage.put.return_value = error_message - - # Mock transmit method to return partial success (206) and trigger track_dropped_items_from_storage - with mock.patch.object(AzureMonitorClient, "track") as mock_track: - # Setup mock for 206 response with one retryable error - mock_track.return_value = TrackResponse( - items_received=2, - items_accepted=1, - errors=[ - TelemetryErrorDetails(index=1, status_code=500, message="should retry"), - ], - ) - - # Call _transmit to trigger the code path - result = exporter._transmit(accepted_envelopes + resend_envelopes) - - # Verify storage.put was called with the resend_envelopes - self.assertEqual(exporter.storage.put.call_count, 1) - - # Verify _track_dropped_items_from_storage was called with the correct parameters - mock_track_dropped_from_storage.assert_called_once_with( - mock_customer_sdkstats, error_message, resend_envelopes - ) - - # Verify _track_dropped_items was called with CLIENT_EXCEPTION from our side_effect - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, resend_envelopes, DropCode.CLIENT_EXCEPTION, error_message - ) - - # Verify final result is FAILED_NOT_RETRYABLE because we already tried to store in offline storage - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_partial_success_206_persistence_capacity_tracking(self, mock_track_dropped, mock_track_dropped_from_storage): - """Test that both _track_dropped_items_from_storage and _track_dropped_items are called correctly - when there's a 206 Partial Success with CLIENT_PERSISTENCE_CAPACITY scenario.""" - # Set up side effect to call the real function but use our mock for _track_dropped_items - def side_effect(statsbeat, result_from_storage, telemetry): - from azure.monitor.opentelemetry.exporter.export._base import _track_dropped_items - if result_from_storage == StorageExportResult.CLIENT_PERSISTENCE_CAPACITY_REACHED: - _track_dropped_items(statsbeat, telemetry, DropCode.CLIENT_PERSISTENCE_CAPACITY) - mock_track_dropped_from_storage.side_effect = side_effect - - # Create base exporter - exporter = BaseExporter() - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Enable storage - exporter.storage = mock.Mock() - - # Create test envelopes for first batch that will be "accepted" - accepted_envelopes = [TelemetryItem(name="accepted", time=datetime.now())] - - # Create test envelopes for second batch that will be "retried" due to 206 - resend_envelopes = [TelemetryItem(name="retried", time=datetime.now())] - - # Mock the storage.put method to return CLIENT_PERSISTENCE_CAPACITY_REACHED - exporter.storage.put.return_value = StorageExportResult.CLIENT_PERSISTENCE_CAPACITY_REACHED - - # Mock transmit method to return partial success (206) and resend_envelopes - exporter._transmit = mock.Mock(return_value=(ExportResult.FAILED_RETRYABLE, resend_envelopes)) - - # Call storage.put directly with resend_envelopes - test_envelopes = accepted_envelopes + resend_envelopes - envelopes_to_store = [x.as_dict() for x in resend_envelopes] - result_from_storage = exporter.storage.put(envelopes_to_store) - - # Call _track_dropped_items_from_storage directly since we're not using the normal flow - mock_track_dropped_from_storage(mock_customer_sdkstats, result_from_storage, resend_envelopes) - - # Verify storage.put was called with the resend_envelopes - self.assertEqual(exporter.storage.put.call_count, 1) - - # Verify _track_dropped_items_from_storage was called with the correct parameters - mock_track_dropped_from_storage.assert_called_once_with( - mock_customer_sdkstats, StorageExportResult.CLIENT_PERSISTENCE_CAPACITY_REACHED, resend_envelopes - ) - - # Verify _track_dropped_items was called with CLIENT_PERSISTENCE_CAPACITY from our side_effect - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, resend_envelopes, DropCode.CLIENT_PERSISTENCE_CAPACITY - ) - - # Verify result_from_storage has the expected value - self.assertEqual(result_from_storage, StorageExportResult.CLIENT_PERSISTENCE_CAPACITY_REACHED) - - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_handle_transmit_from_storage_localfilestorage_general_exception_simulation(self, mock_track_dropped, mock_track_dropped_from_storage): - """Test LocalFileStorage general exception simulation during _check_and_set_folder_permissions""" - # Set up side effect to call the real function but use our mock for _track_dropped_items - def side_effect(statsbeat, result_from_storage_put, telemetry): - from azure.monitor.opentelemetry.exporter.export._base import _track_dropped_items - from azure.monitor.opentelemetry.exporter.statsbeat._utils import get_local_storage_setup_state_exception - if get_local_storage_setup_state_exception() != "": - _track_dropped_items(statsbeat, telemetry, DropCode.CLIENT_EXCEPTION, result_from_storage_put) - elif isinstance(result_from_storage_put, str): - _track_dropped_items(statsbeat, telemetry, DropCode.CLIENT_EXCEPTION, result_from_storage_put) - mock_track_dropped_from_storage.side_effect = side_effect - """Test LocalFileStorage general exception simulation during _check_and_set_folder_permissions""" - # Save original state - original_readonly_state = _LOCAL_STORAGE_SETUP_STATE["READONLY"] - original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - - try: - # Simulate general exception during folder permissions check - general_exception_message = "ValueError: Invalid path format" - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = False - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = general_exception_message - - exporter = BaseExporter(disable_offline_storage=False) - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock storage to return success, but we have exception state from folder permissions - exporter.storage = mock.Mock() - exporter.storage.put.return_value = "/path/to/successful/blob" # Success path - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Call _handle_transmit_from_storage with FAILED_RETRYABLE - result = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify storage.put was called - exporter.storage.put.assert_called_once() - - # Verify that _track_dropped_items was called with CLIENT_EXCEPTION from folder permissions - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_EXCEPTION, "/path/to/successful/blob" - ) - - # Verify exception state was reset after handling - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], general_exception_message) - self.assertFalse(_LOCAL_STORAGE_SETUP_STATE["READONLY"]) - - self.assertIsNone(result) - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = original_readonly_state - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_handle_transmit_from_storage_localfileblob_put_exception_simulation(self, mock_track_dropped, mock_track_dropped_from_storage): - """Test LocalFileBlob.put() exception simulation (file write errors)""" - # Set up side effect to call the real function but use our mock for _track_dropped_items - def side_effect(statsbeat, result_from_storage_put, telemetry): - from azure.monitor.opentelemetry.exporter.export._base import _track_dropped_items - if isinstance(result_from_storage_put, str): - _track_dropped_items(statsbeat, telemetry, DropCode.CLIENT_EXCEPTION, result_from_storage_put) - mock_track_dropped_from_storage.side_effect = side_effect - """Test LocalFileBlob.put() exception simulation (file write errors)""" - # Save original state - original_readonly_state = _LOCAL_STORAGE_SETUP_STATE["READONLY"] - original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - - try: - # Clean state - no prior exceptions - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = False - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = "" - - exporter = BaseExporter(disable_offline_storage=False) - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock storage.put() to return string error (like LocalFileBlob.put() does) - blob_error_message = "[Errno 28] No space left on device" - exporter.storage = mock.Mock() - exporter.storage.put.return_value = blob_error_message - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Call _handle_transmit_from_storage with FAILED_RETRYABLE - result = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify storage.put was called - exporter.storage.put.assert_called_once() - - # Verify that _track_dropped_items was called with CLIENT_EXCEPTION from blob put error - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_EXCEPTION, blob_error_message - ) - - # Verify states remain clean (blob errors don't affect global state) - self.assertFalse(_LOCAL_STORAGE_SETUP_STATE["READONLY"]) - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], "") - - self.assertIsNone(result) - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = original_readonly_state - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_handle_transmit_from_storage_localfileblob_rename_exception_simulation(self, mock_track_dropped, mock_track_dropped_from_storage): - """Test LocalFileBlob.put() rename exception simulation (atomic write failure)""" - # Set up side effect to call the real function but use our mock for _track_dropped_items - def side_effect(statsbeat, result_from_storage_put, telemetry): - from azure.monitor.opentelemetry.exporter.export._base import _track_dropped_items - if isinstance(result_from_storage_put, str): - _track_dropped_items(statsbeat, telemetry, DropCode.CLIENT_EXCEPTION, result_from_storage_put) - mock_track_dropped_from_storage.side_effect = side_effect - """Test LocalFileBlob.put() rename exception simulation (atomic write failure)""" - # Save original state - original_readonly_state = _LOCAL_STORAGE_SETUP_STATE["READONLY"] - original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - - try: - # Clean state - no prior exceptions - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = False - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = "" - - exporter = BaseExporter(disable_offline_storage=False) - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock storage.put() to return string error (rename/atomic write failure) - rename_error_message = "[Errno 1] Operation not permitted: rename failure" - exporter.storage = mock.Mock() - exporter.storage.put.return_value = rename_error_message - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Call _handle_transmit_from_storage with FAILED_RETRYABLE - result = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify storage.put was called - exporter.storage.put.assert_called_once() - - # Verify that _track_dropped_items was called with CLIENT_EXCEPTION from rename error - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_EXCEPTION, rename_error_message - ) - - # Verify states remain clean (blob errors don't affect global state) - self.assertFalse(_LOCAL_STORAGE_SETUP_STATE["READONLY"]) - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], "") - - self.assertIsNone(result) - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = original_readonly_state - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_handle_transmit_from_storage_localfileblob_json_serialization_exception_simulation(self, mock_track_dropped, mock_track_dropped_from_storage): - """Test LocalFileBlob.put() JSON serialization exception simulation""" - # Set up side effect to call the real function but use our mock for _track_dropped_items - def side_effect(statsbeat, result_from_storage_put, telemetry): - from azure.monitor.opentelemetry.exporter.export._base import _track_dropped_items - if isinstance(result_from_storage_put, str): - _track_dropped_items(statsbeat, telemetry, DropCode.CLIENT_EXCEPTION, result_from_storage_put) - mock_track_dropped_from_storage.side_effect = side_effect - """Test LocalFileBlob.put() JSON serialization exception simulation""" - # Save original state - original_readonly_state = _LOCAL_STORAGE_SETUP_STATE["READONLY"] - original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - - try: - # Clean state - no prior exceptions - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = False - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = "" - - exporter = BaseExporter(disable_offline_storage=False) - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock storage.put() to return string error (JSON serialization failure) - json_error_message = "TypeError: Object of type datetime is not JSON serializable" - exporter.storage = mock.Mock() - exporter.storage.put.return_value = json_error_message - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Call _handle_transmit_from_storage with FAILED_RETRYABLE - result = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify storage.put was called - exporter.storage.put.assert_called_once() - - # Verify that _track_dropped_items was called with CLIENT_EXCEPTION from JSON error - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_EXCEPTION, json_error_message - ) - - # Verify states remain clean (blob errors don't affect global state) - self.assertFalse(_LOCAL_STORAGE_SETUP_STATE["READONLY"]) - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], "") - - self.assertIsNone(result) - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = original_readonly_state - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.statsbeat._utils._track_dropped_items') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - def test_handle_transmit_from_storage_combined_folder_permissions_and_blob_errors(self, mock_track_dropped_from_storage, mock_track_dropped): - """Test combination of folder permissions exception state and subsequent blob errors""" - # Save original state - original_readonly_state = _LOCAL_STORAGE_SETUP_STATE["READONLY"] - original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - - try: - # Simulate folder permissions exception occurred during storage setup - folder_exception_message = "OSError: [Errno 13] Permission denied during folder setup" - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = False - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = folder_exception_message - - exporter = BaseExporter(disable_offline_storage=False) - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock storage.put() to also return a blob error - blob_error_message = "IOError: Disk full during blob write" - exporter.storage = mock.Mock() - exporter.storage.put.return_value = blob_error_message - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Set up side_effect for _track_dropped_items_from_storage - def side_effect(customer_sdkstats, result_from_storage_put, envelopes): - from azure.monitor.opentelemetry.exporter.statsbeat._utils import _track_dropped_items_from_storage - # Call the real function which will use our mocked _track_dropped_items - _track_dropped_items_from_storage(customer_sdkstats, result_from_storage_put, envelopes) - - mock_track_dropped_from_storage.side_effect = side_effect - - # Call _handle_transmit_from_storage with FAILED_RETRYABLE - result = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify storage.put was called - exporter.storage.put.assert_called_once() - - # Verify that _track_dropped_items_from_storage was called with the blob error - mock_track_dropped_from_storage.assert_called_once_with( - mock_customer_sdkstats, blob_error_message, test_envelopes - ) - - # Verify that _track_dropped_items was called with CLIENT_EXCEPTION from blob error - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_EXCEPTION, blob_error_message - ) - - # Verify folder exception state was reset after handling - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], folder_exception_message) - self.assertFalse(_LOCAL_STORAGE_SETUP_STATE["READONLY"]) - - self.assertIsNone(result) - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = original_readonly_state - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_handle_transmit_from_storage_readonly_filesystem_with_subsequent_errors(self, mock_track_dropped, mock_track_dropped_from_storage): - """Test readonly filesystem state with subsequent storage errors""" - # Set up side effect to call the real function but use our mock for _track_dropped_items - def side_effect(statsbeat, result_from_storage_put, telemetry): - from azure.monitor.opentelemetry.exporter.export._base import _track_dropped_items - if result_from_storage_put == StorageExportResult.CLIENT_READONLY: - _track_dropped_items(statsbeat, telemetry, DropCode.CLIENT_READONLY) - mock_track_dropped_from_storage.side_effect = side_effect - """Test readonly filesystem state with subsequent storage errors""" - # Save original state - original_readonly_state = _LOCAL_STORAGE_SETUP_STATE["READONLY"] - original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - - try: - # Simulate readonly filesystem detected during folder permissions check - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = True - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = "" - - exporter = BaseExporter(disable_offline_storage=False) - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock storage.put() to return CLIENT_READONLY - exporter.storage = mock.Mock() - exporter.storage.put.return_value = StorageExportResult.CLIENT_READONLY - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Call _handle_transmit_from_storage with FAILED_RETRYABLE - result = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify storage.put was called - exporter.storage.put.assert_called_once() - - # Verify that _track_dropped_items was called with CLIENT_READONLY - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_READONLY - ) - - # Verify readonly state remains True (once set, it stays True) - self.assertTrue(_LOCAL_STORAGE_SETUP_STATE["READONLY"]) - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], "") - - self.assertIsNone(result) - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = original_readonly_state - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_handle_transmit_from_storage_success_path_validation(self, mock_track_dropped): - """Test success path returns LocalFileBlob and doesn't trigger error handling""" - # Save original state - original_readonly_state = _LOCAL_STORAGE_SETUP_STATE["READONLY"] - original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - - try: - # Clean state - no exceptions or readonly - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = False - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = "" - - exporter = BaseExporter(disable_offline_storage=False) - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock storage.put() to return a LocalFileBlob object (not a string) - # The success case is when LocalFileBlob.put() returns self (the LocalFileBlob instance) - # which means storage.put() returns a LocalFileBlob object - success_blob = mock.Mock() # Mock LocalFileBlob object - exporter.storage = mock.Mock() - exporter.storage.put.return_value = success_blob - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Call _handle_transmit_from_storage with FAILED_RETRYABLE - result = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify storage.put was called - exporter.storage.put.assert_called_once() - - # Verify that _track_dropped_items was NOT called (success path - else clause) - mock_track_dropped.assert_not_called() - - # Verify states remain clean (success doesn't affect state) - self.assertFalse(_LOCAL_STORAGE_SETUP_STATE["READONLY"]) - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], "") - - self.assertIsNone(result) - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = original_readonly_state - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_handle_transmit_from_storage_unexpected_return_value(self, mock_track_dropped1, mock_track_dropped2): - """Test that when storage.put() returns an unexpected value type (not StorageExportResult or str), - the method continues without any special handling.""" - exporter = BaseExporter(disable_offline_storage=False) - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock storage.put() to return an unexpected value type (int) - exporter.storage = mock.Mock() - exporter.storage.put.return_value = 42 # Neither StorageExportResult nor str - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify that no dropped items were tracked (since return value isn't handled) - mock_track_dropped1.assert_not_called() - mock_track_dropped2.assert_not_called() - # Verify that the customer sdkstats wasn't invoked - mock_customer_sdkstats.assert_not_called() - - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage") - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") - def test_handle_transmit_from_storage_string_return_values_trigger_exception_tracking(self, mock_track_dropped, mock_track_dropped_from_storage): - """Test that string return values from storage.put() trigger CLIENT_EXCEPTION tracking""" - # Set up side effect to call the real function but use our mock for _track_dropped_items - def side_effect(statsbeat, result_from_storage_put, telemetry): - from azure.monitor.opentelemetry.exporter.export._base import _track_dropped_items - if isinstance(result_from_storage_put, str): - _track_dropped_items(statsbeat, telemetry, DropCode.CLIENT_EXCEPTION, result_from_storage_put) - mock_track_dropped_from_storage.side_effect = side_effect - """Test that string return values from storage.put() trigger CLIENT_EXCEPTION tracking""" - # Save original state - original_readonly_state = _LOCAL_STORAGE_SETUP_STATE["READONLY"] - original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - - try: - # Clean state - no exceptions or readonly - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = False - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = "" - - exporter = BaseExporter(disable_offline_storage=False) - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Test different string return values that should trigger exception tracking - string_returns = [ - "/tmp/telemetry/blob_12345.json", # File path (success) - "Permission denied", # Error message - "Disk full", # Error message - "", # Empty string - "Storage error occurred" # Error message - ] - - for string_return in string_returns: - with self.subTest(string_return=string_return): - # Reset mock for each test - mock_track_dropped.reset_mock() - - # Mock storage.put() to return string value - exporter.storage = mock.Mock() - exporter.storage.put.return_value = string_return - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Call _handle_transmit_from_storage with FAILED_RETRYABLE - result = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify storage.put was called - exporter.storage.put.assert_called_once() - - # Verify that _track_dropped_items WAS called (string triggers exception tracking) - mock_track_dropped.assert_called_once_with( - mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_EXCEPTION, string_return - ) - - # Verify states remain clean (storage.put() string errors don't affect global state) - self.assertFalse(_LOCAL_STORAGE_SETUP_STATE["READONLY"]) - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], "") - - self.assertIsNone(result) - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = original_readonly_state - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.statsbeat._utils._track_dropped_items') - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items_from_storage') - def test_LOCAL_STORAGE_SETUP_STATE_readonly_and_exception_mixed_scenarios(self, mock_track_dropped_from_storage, mock_track_dropped): - """Test mixed scenarios where both readonly and exception conditions occur""" - # Save original state - original_readonly_state = _LOCAL_STORAGE_SETUP_STATE["READONLY"] - original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - - try: - exporter = BaseExporter(disable_offline_storage=False) - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - exporter.storage = mock.Mock() - - test_envelopes = [TelemetryItem(name="test", time=datetime.now())] - - # Set up side_effect for _track_dropped_items_from_storage - def side_effect(customer_sdkstats, result_from_storage_put, envelopes): - from azure.monitor.opentelemetry.exporter.statsbeat._utils import _track_dropped_items_from_storage - # Call the real function which will use our mocked _track_dropped_items - _track_dropped_items_from_storage(customer_sdkstats, result_from_storage_put, envelopes) - - mock_track_dropped_from_storage.side_effect = side_effect - - # Scenario 1: Start with both states set, handle readonly first - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = True - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = "Storage error occurred" - - # Handle readonly condition - exporter.storage.put.return_value = StorageExportResult.CLIENT_READONLY - result1 = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify readonly remains True (once set, it stays True), exception state preserved - self.assertTrue(_LOCAL_STORAGE_SETUP_STATE["READONLY"]) - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], "Storage error occurred") - self.assertIsNone(result1) - - # Verify track_dropped_items_from_storage was called with readonly result - mock_track_dropped_from_storage.assert_called_with(mock_customer_sdkstats, StorageExportResult.CLIENT_READONLY, test_envelopes) - - # Verify _track_dropped_items was called with CLIENT_READONLY - mock_track_dropped.assert_called_with(mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_READONLY) - - # Scenario 2: Now handle the remaining exception - mock_track_dropped_from_storage.reset_mock() - mock_track_dropped.reset_mock() - exporter.storage.put.return_value = "File system error: Permission denied" - result2 = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify exception was reset if exception state was updated, readonly state remains True - self.assertTrue(_LOCAL_STORAGE_SETUP_STATE["READONLY"]) - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], "Storage error occurred") - self.assertIsNone(result2) - - # Verify track_dropped_items_from_storage was called with error string - mock_track_dropped_from_storage.assert_called_with(mock_customer_sdkstats, "File system error: Permission denied", test_envelopes) - - # Verify _track_dropped_items was called with CLIENT_EXCEPTION and the error message - mock_track_dropped.assert_called_with(mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_EXCEPTION, "File system error: Permission denied") - - # Scenario 3: Set both states again, handle exception first this time - mock_track_dropped_from_storage.reset_mock() - mock_track_dropped.reset_mock() - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = True - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = "Another error" - - # Handle exception condition first - exporter.storage.put.return_value = "Disk full error" - result3 = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["READONLY"], True) - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], "Another error") - self.assertIsNone(result3) - - # Verify track_dropped_items_from_storage was called with error string - mock_track_dropped_from_storage.assert_called_with(mock_customer_sdkstats, "Disk full error", test_envelopes) - - # Verify _track_dropped_items was called with CLIENT_EXCEPTION and the error message - mock_track_dropped.assert_called_with(mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_EXCEPTION, "Disk full error") - - # Scenario 4: Now handle the remaining readonly condition - mock_track_dropped_from_storage.reset_mock() - mock_track_dropped.reset_mock() - exporter.storage.put.return_value = StorageExportResult.CLIENT_READONLY - result4 = exporter._handle_transmit_from_storage(test_envelopes, ExportResult.FAILED_RETRYABLE) - - # Verify readonly remains True (once set, it stays True), if exception state was changed, should be reset after recording dropped items - self.assertTrue(_LOCAL_STORAGE_SETUP_STATE["READONLY"]) - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], "Another error") - self.assertIsNone(result4) - - # Verify track_dropped_items_from_storage was called with readonly result - mock_track_dropped_from_storage.assert_called_with(mock_customer_sdkstats, StorageExportResult.CLIENT_READONLY, test_envelopes) - - # Verify _track_dropped_items was called with CLIENT_READONLY - mock_track_dropped.assert_called_with(mock_customer_sdkstats, test_envelopes, DropCode.CLIENT_READONLY) - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = original_readonly_state - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state - - def test_local_storage_state_exception_get_set_operations(self): - """Test the validity of get and set operations for exception state in local storage state""" - from azure.monitor.opentelemetry.exporter.statsbeat._state import ( - get_local_storage_setup_state_exception, - set_local_storage_setup_state_exception, - _LOCAL_STORAGE_SETUP_STATE, - _LOCAL_STORAGE_SETUP_STATE_LOCK - ) - - # Save original state - original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - - try: - # Test 1: Initial state should be None - self.assertEqual(get_local_storage_setup_state_exception(), "") - - # Test 2: Set string value and verify get operation - test_error = "Test storage exception" - set_local_storage_setup_state_exception(test_error) - self.assertEqual(get_local_storage_setup_state_exception(), test_error) - - # Test 3: Set empty string and verify get operation - set_local_storage_setup_state_exception("") - self.assertEqual(get_local_storage_setup_state_exception(), "") - - # Test 4: Set complex error message and verify get operation - complex_error = "OSError: [Errno 28] No space left on device: '/tmp/storage/file.blob'" - set_local_storage_setup_state_exception(complex_error) - self.assertEqual(get_local_storage_setup_state_exception(), complex_error) - - # Test 5: Verify thread safety by directly accessing state - with _LOCAL_STORAGE_SETUP_STATE_LOCK: - direct_value = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - self.assertEqual(direct_value, complex_error) - self.assertEqual(get_local_storage_setup_state_exception(), direct_value) - - # Test 6: Test multiple rapid set/get operations - test_values = [ - "Error 1", - "Error 2", - "Error 3", - "", - "Final error" - ] - - for value in test_values: - with self.subTest(value=value): - set_local_storage_setup_state_exception(value) - self.assertEqual(get_local_storage_setup_state_exception(), value) - - # Test 8: Verify that set operation doesn't affect other state values - original_readonly = _LOCAL_STORAGE_SETUP_STATE["READONLY"] - set_local_storage_setup_state_exception("New exception") - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["READONLY"], original_readonly) - self.assertEqual(get_local_storage_setup_state_exception(), "New exception") - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state - - def test_local_storage_state_exception_concurrent_access(self): - """Test concurrent access to exception state get/set operations""" - import threading - import time - from azure.monitor.opentelemetry.exporter.statsbeat._state import ( - get_local_storage_setup_state_exception, - set_local_storage_setup_state_exception, - _LOCAL_STORAGE_SETUP_STATE - ) - - # Save original state - original_exception_state = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - results = [] - errors = [] - - def worker_thread(thread_id): - try: - for i in range(10): - # Set a unique value - value = f"Thread-{thread_id}-Error-{i}" - set_local_storage_setup_state_exception(value) - - # Small delay to increase chance of race conditions - time.sleep(0.001) - - # Get the value and verify it's either our value or another thread's value - retrieved_value = get_local_storage_setup_state_exception() - results.append((thread_id, i, value, retrieved_value)) - - # Verify it's a valid value (either ours or from another thread) - if retrieved_value is not None: - self.assertIsInstance(retrieved_value, str) - self.assertTrue(retrieved_value.startswith("Thread-")) - except Exception as e: - errors.append(f"Thread {thread_id}: {e}") - - try: - # Reset to original state - set_local_storage_setup_state_exception("") - - # Start multiple threads - threads = [] - for i in range(5): - thread = threading.Thread(target=worker_thread, args=(i,)) - threads.append(thread) - thread.start() - - # Wait for all threads to complete - for thread in threads: - thread.join() - - # Verify no errors occurred - self.assertEqual(len(errors), 0, f"Errors in concurrent access: {errors}") - - # Verify we got results from all threads - self.assertEqual(len(results), 50) # 5 threads * 10 operations each - - # Verify final state is valid - final_value = get_local_storage_setup_state_exception() - if final_value is not None: - self.assertIsInstance(final_value, str) - self.assertTrue(final_value.startswith("Thread-")) - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] = original_exception_state - - def test_local_storage_state_readonly_get_operations(self): - """Test the get operation for readonly state in local storage state""" - from azure.monitor.opentelemetry.exporter.statsbeat._state import ( - get_local_storage_setup_state_readonly, - _LOCAL_STORAGE_SETUP_STATE, - _LOCAL_STORAGE_SETUP_STATE_LOCK - ) - - # Save original state - original_readonly_state = _LOCAL_STORAGE_SETUP_STATE["READONLY"] - - try: - # Test 1: Initial state should be False - self.assertEqual(get_local_storage_setup_state_readonly(), False) - - # Test 2: Set True directly and verify get operation - with _LOCAL_STORAGE_SETUP_STATE_LOCK: - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = True - self.assertEqual(get_local_storage_setup_state_readonly(), True) - - # Test 3: Set False directly and verify get operation - with _LOCAL_STORAGE_SETUP_STATE_LOCK: - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = False - self.assertEqual(get_local_storage_setup_state_readonly(), False) - - # Test 4: Verify get operation doesn't affect other state values - original_exception = _LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"] - with _LOCAL_STORAGE_SETUP_STATE_LOCK: - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = True - - # Get readonly state multiple times - for _ in range(5): - self.assertEqual(get_local_storage_setup_state_readonly(), True) - - # Verify exception state wasn't affected - self.assertEqual(_LOCAL_STORAGE_SETUP_STATE["EXCEPTION_OCCURRED"], original_exception) - - finally: - # Restore original state - _LOCAL_STORAGE_SETUP_STATE["READONLY"] = original_readonly_state - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_should_collect_customer_sdkstats_enabled(self): - exporter = BaseExporter(disable_offline_storage=True) - self.assertTrue(exporter._should_collect_customer_sdkstats()) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "false", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "false", - }, - ) - def test_should_collect_customer_sdkstats_disabled(self): - exporter = BaseExporter(disable_offline_storage=True) - self.assertFalse(exporter._should_collect_customer_sdkstats()) - - def test_should_collect_customer_sdkstats_env_not_set(self): - with mock.patch.dict(os.environ, {}, clear=True): - exporter = BaseExporter( - connection_string="InstrumentationKey=363331ca-f431-4119-bdcd-31a75920f958;IngestionEndpoint=https://eastus-8.in.applicationinsights.azure.com/", - disable_offline_storage=True - ) - self.assertFalse(exporter._should_collect_customer_sdkstats()) - - def test_should_collect_customer_sdkstats_instrumentation_collection(self): - with mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ): - exporter = BaseExporter(disable_offline_storage=True, instrumentation_collection=True) - self.assertFalse(exporter._should_collect_customer_sdkstats()) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_base_exporter_storage_put_readonly_tracked(self, mock_track_dropped): - """Test that BaseExporter tracks CLIENT_READONLY when storage.put() returns CLIENT_READONLY""" - exporter = BaseExporter(disable_offline_storage=False) - - # Setup customer sdkstats - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock the storage to return CLIENT_READONLY - exporter.storage = mock.Mock() - exporter.storage.gets.return_value = [] # No blobs from storage - exporter.storage.put.return_value = StorageExportResult.CLIENT_READONLY - - # This should trigger the storage status check in _transmit_from_storage - exporter._transmit_from_storage() - - # Verify that _track_dropped_items was called with CLIENT_READONLY - # Note: Since no envelopes are processed from storage, this should not be called in current implementation - mock_track_dropped.assert_not_called() - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_base_exporter_storage_put_exception_tracked(self, mock_track_dropped): - """Test that BaseExporter tracks CLIENT_EXCEPTION when storage.put() returns an error string""" - exporter = BaseExporter(disable_offline_storage=False) - - # Setup customer sdkstats - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock the storage to return an error string - exporter.storage = mock.Mock() - exporter.storage.gets.return_value = [] # No blobs from storage - error_message = "Storage write failed: Permission denied" - exporter.storage.put.return_value = error_message - - # This should trigger the storage status check in _transmit_from_storage - exporter._transmit_from_storage() - - # Verify that _track_dropped_items was called with CLIENT_EXCEPTION - # Note: Since no envelopes are processed from storage, this should not be called in current implementation - mock_track_dropped.assert_not_called() - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - @mock.patch('azure.monitor.opentelemetry.exporter.export._base._track_dropped_items') - def test_base_exporter_storage_put_capacity_reached_tracked(self, mock_track_dropped): - """Test that BaseExporter tracks CLIENT_PERSISTENCE_CAPACITY when storage.put() returns CLIENT_PERSISTENCE_CAPACITY_REACHED""" - exporter = BaseExporter(disable_offline_storage=False) - - # Setup customer sdkstats - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - - # Mock the storage to return CLIENT_PERSISTENCE_CAPACITY_REACHED - exporter.storage = mock.Mock() - exporter.storage.gets.return_value = [] # No blobs from storage - exporter.storage.put.return_value = StorageExportResult.CLIENT_PERSISTENCE_CAPACITY_REACHED - - # This should trigger the storage status check in _transmit_from_storage - exporter._transmit_from_storage() - - # Verify that _track_dropped_items was called with CLIENT_PERSISTENCE_CAPACITY - # Note: Since no envelopes are processed from storage, this should not be called in current implementation - mock_track_dropped.assert_not_called() - - # Custom Breeze Message Handling Tests - # These tests verify that custom error messages from Azure Monitor service (Breeze) - # are properly preserved and passed through the error handling chain. - - def test_determine_client_retry_code_telemetry_error_details_with_custom_message(self): - """Test that TelemetryErrorDetails with custom message preserves the message for specific status codes.""" - exporter = BaseExporter(disable_offline_storage=True) - - # Test various specific status codes with custom messages - test_cases = [ - (401, "Authentication failed. Please check your instrumentation key."), - (403, "Forbidden access. Verify your permissions for this resource."), - (408, "Request timeout. The service took too long to respond."), - (429, "Rate limit exceeded for instrumentation key. Current rate: 1000 req/min, limit: 500 req/min."), - (500, "Internal server error. Please try again later."), - (502, "Bad gateway. The upstream server is unavailable."), - (503, "Service unavailable. The monitoring service is temporarily down."), - (504, "Gateway timeout. The request timed out while waiting for the upstream server."), - ] - - for status_code, custom_message in test_cases: - with self.subTest(status_code=status_code): - error = TelemetryErrorDetails( - index=0, - status_code=status_code, - message=custom_message - ) - - retry_code, message = _determine_client_retry_code(error) - self.assertEqual(retry_code, status_code) - self.assertEqual(message, custom_message) - - def test_determine_client_retry_code_telemetry_error_details_without_message(self): - """Test that TelemetryErrorDetails without message returns _UNKNOWN for specific status codes.""" - exporter = BaseExporter(disable_offline_storage=True) - - status_codes = [401, 403, 408, 429, 500, 502, 503, 504] - - for status_code in status_codes: - with self.subTest(status_code=status_code): - error = TelemetryErrorDetails( - index=0, - status_code=status_code, - message=None - ) - - retry_code, message = _determine_client_retry_code(error) - self.assertEqual(retry_code, status_code) - self.assertEqual(message, _UNKNOWN) - - def test_determine_client_retry_code_telemetry_error_details_empty_message(self): - """Test that TelemetryErrorDetails with empty message returns _UNKNOWN for specific status codes.""" - exporter = BaseExporter(disable_offline_storage=True) - - status_codes = [401, 403, 408, 429, 500, 502, 503, 504] - - for status_code in status_codes: - with self.subTest(status_code=status_code): - error = TelemetryErrorDetails( - index=0, - status_code=status_code, - message="" - ) - - retry_code, message = _determine_client_retry_code(error) - self.assertEqual(retry_code, status_code) - self.assertEqual(message, _UNKNOWN) - - def test_determine_client_retry_code_http_response_error_with_custom_message(self): - """Test that HttpResponseError with custom message preserves the message for specific status codes.""" - exporter = BaseExporter(disable_offline_storage=True) - - test_cases = [ - (429, "Rate limit exceeded. Please reduce your request rate."), - (500, "Internal server error occurred during telemetry processing."), - (503, "Service temporarily unavailable due to high load."), - ] - - for status_code, custom_message in test_cases: - with self.subTest(status_code=status_code): - error = HttpResponseError() - error.status_code = status_code - error.message = custom_message - - retry_code, message = _determine_client_retry_code(error) - self.assertEqual(retry_code, status_code) - self.assertEqual(message, custom_message) - - def test_determine_client_retry_code_generic_error_with_message_attribute(self): - """Test that generic errors with message attribute preserve the message for specific status codes.""" - exporter = BaseExporter(disable_offline_storage=True) - - test_cases = [ - (401, "Custom auth error from service"), - (429, "Custom rate limit message"), - (500, "Custom server error message"), - ] - - for status_code, custom_message in test_cases: - with self.subTest(status_code=status_code): - error = mock.Mock() - error.status_code = status_code - error.message = custom_message - - retry_code, message = _determine_client_retry_code(error) - self.assertEqual(retry_code, status_code) - self.assertEqual(message, custom_message) - - def test_determine_client_retry_code_non_specific_status_codes(self): - """Test that non-specific status codes are handled with CLIENT_EXCEPTION.""" - exporter = BaseExporter(disable_offline_storage=True) - - # Test non-specific status codes - non_specific_codes = [400, 404, 410, 413] - - for status_code in non_specific_codes: - with self.subTest(status_code=status_code): - error = mock.Mock() - error.status_code = status_code - error.message = f"Error message for {status_code}" - - retry_code, message = _determine_client_retry_code(error) - self.assertEqual(retry_code, RetryCode.CLIENT_EXCEPTION) - self.assertEqual(message, str(error)) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_track_retry_items_with_custom_breeze_messages(self): - """Test that _track_retry_items properly passes custom messages from Breeze errors.""" - exporter = BaseExporter(disable_offline_storage=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - # Create test envelopes - envelopes = [TelemetryItem(name="Test", time=datetime.now())] - - # Test TelemetryErrorDetails with custom message - error = TelemetryErrorDetails( - index=0, - status_code=429, - message="Rate limit exceeded for instrumentation key. Current rate: 1000 req/min, limit: 500 req/min." - ) - - _track_retry_items(exporter._customer_sdkstats_metrics, envelopes, error) - - # Verify that count_retry_items was called with the custom message - exporter._customer_sdkstats_metrics.count_retry_items.assert_called_once_with( - 1, - 'UNKNOWN', # telemetry type - 429, # status code - 'Rate limit exceeded for instrumentation key. Current rate: 1000 req/min, limit: 500 req/min.' - ) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_track_retry_items_with_http_response_error_custom_message(self): - """Test that _track_retry_items properly passes custom messages from HttpResponseError.""" - exporter = BaseExporter(disable_offline_storage=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - # Create test envelopes - envelopes = [TelemetryItem(name="Test", time=datetime.now())] - - # Test HttpResponseError with custom message - error = HttpResponseError() - error.status_code = 503 - error.message = "Service temporarily unavailable due to maintenance." - - _track_retry_items(exporter._customer_sdkstats_metrics, envelopes, error) - - # Verify that count_retry_items was called with the custom message - exporter._customer_sdkstats_metrics.count_retry_items.assert_called_once_with( - 1, - 'UNKNOWN', # telemetry type - 503, # status code - 'Service temporarily unavailable due to maintenance.' - ) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_track_retry_items_without_custom_message(self): - """Test that _track_retry_items handles errors without custom messages.""" - exporter = BaseExporter(disable_offline_storage=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - # Create test envelopes - envelopes = [TelemetryItem(name="Test", time=datetime.now())] - - # Test error without message attribute for specific status code - error = mock.Mock(spec=['status_code']) # Only specify status_code attribute - error.status_code = 500 - - _track_retry_items(exporter._customer_sdkstats_metrics, envelopes, error) - - # Verify that count_retry_items was called with _UNKNOWN message for specific status codes without custom message - exporter._customer_sdkstats_metrics.count_retry_items.assert_called_once_with( - 1, - 'UNKNOWN', # telemetry type - 500, # status code - 'UNKNOWN' # message (_UNKNOWN is returned for specific status codes without custom message) - ) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_track_retry_items_service_request_error_with_message(self): - """Test that _track_retry_items properly handles ServiceRequestError with message.""" - exporter = BaseExporter(disable_offline_storage=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - # Create test envelopes - envelopes = [TelemetryItem(name="Test", time=datetime.now())] - - # Test ServiceRequestError with message (using "timeout" to test timeout detection) - error = ServiceRequestError("Connection timeout occurred") - - _track_retry_items(exporter._customer_sdkstats_metrics, envelopes, error) - - # Verify that count_retry_items was called with CLIENT_TIMEOUT - exporter._customer_sdkstats_metrics.count_retry_items.assert_called_once_with( - 1, - 'UNKNOWN', # telemetry type - RetryCode.CLIENT_TIMEOUT, # retry code - 'Connection timeout occurred' # message - ) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_track_retry_items_service_request_error_no_timeout(self): - """Test that _track_retry_items properly handles ServiceRequestError without timeout in message.""" - exporter = BaseExporter(disable_offline_storage=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - # Create test envelopes - envelopes = [TelemetryItem(name="Test", time=datetime.now())] - - # Test ServiceRequestError with message that doesn't contain "timeout" - error = ServiceRequestError("Connection failed") - - _track_retry_items(exporter._customer_sdkstats_metrics, envelopes, error) - - # Verify that count_retry_items was called with CLIENT_EXCEPTION - exporter._customer_sdkstats_metrics.count_retry_items.assert_called_once_with( - 1, - 'UNKNOWN', # telemetry type - RetryCode.CLIENT_EXCEPTION, # retry code - 'Connection failed' # message - ) - - def test_determine_client_retry_code_http_status_codes(self): - exporter = BaseExporter(disable_offline_storage=True) - - status_codes = [401, 403, 408, 429, 500, 502, 503, 504] - - for status_code in status_codes: - # Create mock without message attribute to test _UNKNOWN fallback - error = mock.Mock(spec=['status_code']) - error.status_code = status_code - - retry_code, message = _determine_client_retry_code(error) - self.assertEqual(retry_code, status_code) - self.assertEqual(message, _UNKNOWN) - - def test_determine_client_retry_code_service_request_error(self): - exporter = BaseExporter(disable_offline_storage=True) - - error = ServiceRequestError("Connection failed") - - retry_code, message = _determine_client_retry_code(error) - self.assertEqual(retry_code, RetryCode.CLIENT_EXCEPTION) - self.assertEqual(message, "Connection failed") - - def test_determine_client_retry_code_service_request_error_with_message(self): - exporter = BaseExporter(disable_offline_storage=True) - - error = ServiceRequestError("Network error") - error.message = "Specific network error" - - retry_code, message = _determine_client_retry_code(error) - self.assertEqual(retry_code, RetryCode.CLIENT_EXCEPTION) - self.assertEqual(message, "Specific network error") - - def test_determine_client_retry_code_timeout_error(self): - exporter = BaseExporter(disable_offline_storage=True) - - # customer sdkstats Flag Regression Tests - # These tests ensure that the _is_customer_sdkstats_exporter() method using - # getattr(self, '_is_customer_sdkstats', False) works correctly across - # all scenarios and edge cases. - - def test_regular_exporter_not_flagged_as_customer_sdkstats(self): - """Test that regular exporters are not identified as customer sdkstats exporters.""" - # Test BaseExporter - base_exporter = BaseExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - self.assertFalse(base_exporter._is_customer_sdkstats_exporter()) - - # Test AzureMonitorTraceExporter - trace_exporter = AzureMonitorTraceExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - self.assertFalse(trace_exporter._is_customer_sdkstats_exporter()) - - # Test AzureMonitorMetricExporter - metric_exporter = AzureMonitorMetricExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - self.assertFalse(metric_exporter._is_customer_sdkstats_exporter()) - - def test_statsbeat_exporter_not_flagged_as_customer_sdkstats(self): - """Test that regular statsbeat exporter is not identified as customer sdkstats exporter.""" - statsbeat_exporter = _StatsBeatExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - self.assertFalse(statsbeat_exporter._is_customer_sdkstats_exporter()) - - def test_customer_sdkstats_exporter_properly_flagged(self): - """Test that customer sdkstats exporter is properly identified when flag is set.""" - # Create a metric exporter and manually set the customer sdkstats flag - exporter = AzureMonitorMetricExporter( - connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc", - instrumentation_collection=True - ) - - # Verify initially not flagged - self.assertFalse(exporter._is_customer_sdkstats_exporter()) - - # Set the customer sdkstats flag - exporter._is_customer_sdkstats = True - - # Verify now properly flagged - self.assertTrue(exporter._is_customer_sdkstats_exporter()) - - def test_flag_attribute_missing_returns_false(self): - """Test that missing _is_customer_sdkstats attribute returns False (default behavior).""" - exporter = BaseExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - - # Ensure the attribute doesn't exist - self.assertFalse(hasattr(exporter, '_is_customer_sdkstats')) - - # Verify getattr returns False as default - self.assertFalse(exporter._is_customer_sdkstats_exporter()) - - def test_flag_attribute_false_returns_false(self): - """Test that _is_customer_sdkstats = False explicitly returns False.""" - exporter = BaseExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - exporter._is_customer_sdkstats = False - - self.assertFalse(exporter._is_customer_sdkstats_exporter()) - - def test_flag_attribute_true_returns_true(self): - """Test that _is_customer_sdkstats = True returns True.""" - exporter = BaseExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - exporter._is_customer_sdkstats = True - - self.assertTrue(exporter._is_customer_sdkstats_exporter()) - - def test_flag_attribute_none_returns_false(self): - """Test that _is_customer_sdkstats = None returns False.""" - exporter = BaseExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - exporter._is_customer_sdkstats = None - - self.assertFalse(exporter._is_customer_sdkstats_exporter()) - - def test_flag_attribute_other_values_behavior(self): - """Test behavior with various non-boolean values for the flag.""" - exporter = BaseExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - - # Test with string "true" - should be truthy - exporter._is_customer_sdkstats = "true" - self.assertTrue(exporter._is_customer_sdkstats_exporter()) - - # Test with string "false" - should be truthy (non-empty string) - exporter._is_customer_sdkstats = "false" - self.assertTrue(exporter._is_customer_sdkstats_exporter()) - - # Test with empty string - should be falsy - exporter._is_customer_sdkstats = "" - self.assertFalse(exporter._is_customer_sdkstats_exporter()) - - # Test with number 1 - should be truthy - exporter._is_customer_sdkstats = 1 - self.assertTrue(exporter._is_customer_sdkstats_exporter()) - - # Test with number 0 - should be falsy - exporter._is_customer_sdkstats = 0 - self.assertFalse(exporter._is_customer_sdkstats_exporter()) - - @mock.patch.dict(os.environ, {"APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true"}) - def test_should_collect_customer_sdkstats_with_regular_exporter_flag_test(self): - """Test that regular exporters should collect customer sdkstats when enabled.""" - # Mock customer sdkstats shutdown state and storage method - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._state.get_customer_sdkstats_shutdown", return_value=False), \ - mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.collect_customer_sdkstats"): - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc", - disable_offline_storage=True # Disable storage to avoid missing method issue - ) - - # Regular exporter should collect customer sdkstats - self.assertTrue(exporter._should_collect_customer_sdkstats()) - - @mock.patch.dict(os.environ, {"APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true"}) - def test_should_collect_customer_sdkstats_with_customer_sdkstats_exporter_flag_test(self): - """Test that customer sdkstats exporters should NOT collect customer sdkstats.""" - # Mock customer sdkstats shutdown state - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._state.get_customer_sdkstats_shutdown", return_value=False): - exporter = AzureMonitorMetricExporter( - connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc", - instrumentation_collection=True - ) - exporter._is_customer_sdkstats = True - - # customer sdkstats exporter should NOT collect customer sdkstats (prevents recursion) - self.assertFalse(exporter._should_collect_customer_sdkstats()) - - def test_customer_sdkstats_metrics_creation_with_flag_test(self): - """Test that CustomerSdkStatsMetrics properly sets the flag on its exporter.""" - original_env = os.environ.get("APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW") - os.environ["APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW"] = "true" - - try: - # Mock to prevent actual metric collection setup - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.PeriodicExportingMetricReader"), \ - mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.MeterProvider"), \ - mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.get_compute_type", return_value="vm"): - connection_string = "InstrumentationKey=12345678-1234-1234-1234-123456789abc" - - customer_sdkstats = CustomerSdkStatsMetrics(connection_string) - - # Verify that the exporter was created and flagged - self.assertTrue(hasattr(customer_sdkstats, '_customer_sdkstats_exporter')) - self.assertTrue(customer_sdkstats._customer_sdkstats_exporter._is_customer_sdkstats_exporter()) - - finally: - if original_env is not None: - os.environ["APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW"] = original_env - else: - os.environ.pop("APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW", None) - - def test_multiple_exporters_independent_flags(self): - """Test that multiple exporters can have independent flag states.""" - # Create multiple exporters - exporter1 = AzureMonitorMetricExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - exporter2 = AzureMonitorMetricExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - exporter3 = AzureMonitorTraceExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - - # Initially, none should be flagged - self.assertFalse(exporter1._is_customer_sdkstats_exporter()) - self.assertFalse(exporter2._is_customer_sdkstats_exporter()) - self.assertFalse(exporter3._is_customer_sdkstats_exporter()) - - # Flag only exporter2 - exporter2._is_customer_sdkstats = True - - # Verify only exporter2 is flagged - self.assertFalse(exporter1._is_customer_sdkstats_exporter()) - self.assertTrue(exporter2._is_customer_sdkstats_exporter()) - self.assertFalse(exporter3._is_customer_sdkstats_exporter()) - - # Flag exporter3 - exporter3._is_customer_sdkstats = True - - # Verify exporter2 and exporter3 are flagged, but not exporter1 - self.assertFalse(exporter1._is_customer_sdkstats_exporter()) - self.assertTrue(exporter2._is_customer_sdkstats_exporter()) - self.assertTrue(exporter3._is_customer_sdkstats_exporter()) - - def test_flag_modification_after_creation(self): - """Test that flag can be modified after exporter creation.""" - exporter = BaseExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - - # Initially not flagged - self.assertFalse(exporter._is_customer_sdkstats_exporter()) - - # Set flag - exporter._is_customer_sdkstats = True - self.assertTrue(exporter._is_customer_sdkstats_exporter()) - - # Unset flag - exporter._is_customer_sdkstats = False - self.assertFalse(exporter._is_customer_sdkstats_exporter()) - - # Delete flag attribute - delattr(exporter, '_is_customer_sdkstats') - self.assertFalse(exporter._is_customer_sdkstats_exporter()) - - def test_getattr_with_different_default_values(self): - """Test that getattr behavior is consistent with different theoretical default values.""" - exporter = BaseExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - - # Test current implementation (default False) - self.assertEqual(getattr(exporter, '_is_customer_sdkstats', False), False) - - # Test what would happen with different defaults - self.assertEqual(getattr(exporter, '_is_customer_sdkstats', True), True) - self.assertEqual(getattr(exporter, '_is_customer_sdkstats', None), None) - self.assertEqual(getattr(exporter, '_is_customer_sdkstats', "default"), "default") - - # Set the attribute and verify getattr returns the actual value regardless of default - exporter._is_customer_sdkstats = True - self.assertEqual(getattr(exporter, '_is_customer_sdkstats', False), True) - self.assertEqual(getattr(exporter, '_is_customer_sdkstats', "other"), True) - - @mock.patch.dict(os.environ, {"APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true"}) - def test_integration_scenario_mixed_exporters_flag_test(self): - """Integration test with mixed exporter types to ensure no interference.""" - # Mock customer sdkstats shutdown state and storage method - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._state.get_customer_sdkstats_shutdown", return_value=False), \ - mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.collect_customer_sdkstats"): - # Create various types of exporters with storage disabled - trace_exporter = AzureMonitorTraceExporter( - connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc", - disable_offline_storage=True - ) - metric_exporter = AzureMonitorMetricExporter( - connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc", - disable_offline_storage=True - ) - - # Create a customer sdkstats exporter - customer_sdkstats_exporter = AzureMonitorMetricExporter( - connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc", - instrumentation_collection=True, - disable_offline_storage=True - ) - customer_sdkstats_exporter._is_customer_sdkstats = True - - # Verify identification - self.assertFalse(trace_exporter._is_customer_sdkstats_exporter()) - self.assertFalse(metric_exporter._is_customer_sdkstats_exporter()) - self.assertTrue(customer_sdkstats_exporter._is_customer_sdkstats_exporter()) - - # Verify collection logic - self.assertTrue(trace_exporter._should_collect_customer_sdkstats()) - self.assertTrue(metric_exporter._should_collect_customer_sdkstats()) - self.assertFalse(customer_sdkstats_exporter._should_collect_customer_sdkstats()) - - def test_inheritance_flag_behavior(self): - """Test that flag behavior works correctly with inheritance.""" - class CustomExporter(BaseExporter): - def __init__(self, **kwargs): - super().__init__(**kwargs) - - custom_exporter = CustomExporter(connection_string="InstrumentationKey=12345678-1234-1234-1234-123456789abc") - - # Should work the same as BaseExporter - self.assertFalse(custom_exporter._is_customer_sdkstats_exporter()) - - custom_exporter._is_customer_sdkstats = True - self.assertTrue(custom_exporter._is_customer_sdkstats_exporter()) - - # End of customer sdkstats Flag Regression Tests - - def test_determine_client_retry_code_timeout_error(self): - exporter = BaseExporter(disable_offline_storage=True) - - timeout_error = ServiceRequestError("Request timed out") - - retry_code, message = _determine_client_retry_code(timeout_error) - self.assertEqual(retry_code, RetryCode.CLIENT_TIMEOUT) - self.assertEqual(message, "Request timed out") - - timeout_error2 = ServiceRequestError("Connection timeout occurred") - - retry_code2, message2 = _determine_client_retry_code(timeout_error2) - self.assertEqual(retry_code2, RetryCode.CLIENT_TIMEOUT) - self.assertEqual(message2, "Connection timeout occurred") - - def test_determine_client_retry_code_general_exception(self): - exporter = BaseExporter(disable_offline_storage=True) - - error = Exception("Something went wrong") - - retry_code, message = _determine_client_retry_code(error) - self.assertEqual(retry_code, RetryCode.CLIENT_EXCEPTION) - self.assertEqual(message, "Something went wrong") - - def test_track_retry_items_stats_exporter(self): - exporter = _StatsBeatExporter(disable_offline_storage=True) - - mock_customer_sdkstats = mock.Mock() - exporter._customer_sdkstats_metrics = mock_customer_sdkstats - - test_envelopes = [TelemetryItem(name="test1", time=datetime.now())] - - error = Exception("Some error") - - # Only call _track_retry_items if should_collect_customer_sdkstats is True - if exporter._customer_sdkstats_metrics and exporter._should_collect_customer_sdkstats(): - _track_retry_items(exporter._customer_sdkstats_metrics, test_envelopes, error) - - mock_customer_sdkstats.count_retry_items.assert_not_called() - - def test_track_retry_items_no_customer_sdkstats(self): - exporter = BaseExporter(disable_offline_storage=True) - - self.assertIsNone(exporter._customer_sdkstats_metrics) - - test_envelopes = [TelemetryItem(name="test1", time=datetime.now())] - - error = Exception("Some error") - - _track_retry_items(exporter._customer_sdkstats_metrics, test_envelopes, error) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_track_retry_items_with_customer_sdkstats(self): - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.collect_customer_sdkstats") as mock_collect: - mock_customer_sdkstats = mock.Mock() - - def mock_collect_side_effect(exporter): - setattr(exporter, '_customer_sdkstats_metrics', mock_customer_sdkstats) - - mock_collect.side_effect = mock_collect_side_effect - - exporter = BaseExporter(disable_offline_storage=True) - - test_envelopes = [ - TelemetryItem(name="test1", time=datetime.now()), - TelemetryItem(name="test2", time=datetime.now()), - ] - - error = ServiceRequestError("Connection failed") - _track_retry_items(exporter._customer_sdkstats_metrics, test_envelopes, error) - - self.assertEqual(mock_customer_sdkstats.count_retry_items.call_count, 2) - - calls = mock_customer_sdkstats.count_retry_items.call_args_list - self.assertEqual(calls[0][0][0], 1) - self.assertEqual(calls[0][0][2], RetryCode.CLIENT_EXCEPTION) - self.assertEqual(calls[1][0][0], 1) - self.assertEqual(calls[1][0][2], RetryCode.CLIENT_EXCEPTION) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_track_retry_items_with_status_code_error(self): - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.collect_customer_sdkstats") as mock_collect: - mock_customer_sdkstats = mock.Mock() - - def mock_collect_side_effect(exporter): - setattr(exporter, '_customer_sdkstats_metrics', mock_customer_sdkstats) - - mock_collect.side_effect = mock_collect_side_effect - - exporter = BaseExporter(disable_offline_storage=True) - - test_envelopes = [TelemetryItem(name="test1", time=datetime.now())] - - error = HttpResponseError() - error.status_code = 429 - _track_retry_items(exporter._customer_sdkstats_metrics, test_envelopes, error) - - mock_customer_sdkstats.count_retry_items.assert_called_once() - - args, kwargs = mock_customer_sdkstats.count_retry_items.call_args - self.assertEqual(args[0], 1) - self.assertEqual(args[2], 429) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_transmission_success_tracks_successful_items(self): - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.collect_customer_sdkstats") as mock_collect: - mock_customer_sdkstats = mock.Mock() - - def mock_collect_side_effect(exporter): - setattr(exporter, '_customer_sdkstats_metrics', mock_customer_sdkstats) - - mock_collect.side_effect = mock_collect_side_effect - - exporter = BaseExporter(disable_offline_storage=True) - - test_envelopes = [ - TelemetryItem(name="test1", time=datetime.now()), - TelemetryItem(name="test2", time=datetime.now()), - ] - - with mock.patch.object(AzureMonitorClient, "track") as mock_track: - mock_track.return_value = TrackResponse( - items_received=2, - items_accepted=2, - errors=[], - ) - - result = exporter._transmit(test_envelopes) - - self.assertEqual(result, ExportResult.SUCCESS) - - self.assertEqual(mock_customer_sdkstats.count_successful_items.call_count, 2) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_transmission_206_tracks_dropped_items(self): - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.collect_customer_sdkstats") as mock_collect: - mock_customer_sdkstats = mock.Mock() - - def mock_collect_side_effect(exporter): - setattr(exporter, '_customer_sdkstats_metrics', mock_customer_sdkstats) - - mock_collect.side_effect = mock_collect_side_effect - - exporter = BaseExporter(disable_offline_storage=True) - - test_envelopes = [ - TelemetryItem(name="test1", time=datetime.now()), - TelemetryItem(name="test2", time=datetime.now()), - TelemetryItem(name="test3", time=datetime.now()), - ] - - with mock.patch.object(AzureMonitorClient, "track") as mock_track: - mock_track.return_value = TrackResponse( - items_received=3, - items_accepted=2, - errors=[ - TelemetryErrorDetails( - index=0, - status_code=400, - message="Invalid data", - ), - ], - ) - - result = exporter._transmit(test_envelopes) - - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - - mock_customer_sdkstats.count_dropped_items.assert_called_once() - - args, kwargs = mock_customer_sdkstats.count_dropped_items.call_args - self.assertEqual(args[0], 1) # count - self.assertEqual(args[2], 400) # status_code - # The error parameter is now optional, so it's not passed when None - # This means args only has 3 elements, not 4 - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_transmission_206_tracks_retry_items(self): - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.collect_customer_sdkstats") as mock_collect: - mock_customer_sdkstats = mock.Mock() - - def mock_collect_side_effect(exporter): - setattr(exporter, '_customer_sdkstats_metrics', mock_customer_sdkstats) - - mock_collect.side_effect = mock_collect_side_effect - - exporter = BaseExporter(disable_offline_storage=False) - - exporter.storage.put = mock.Mock() - - test_envelopes = [ - TelemetryItem(name="test1", time=datetime.now()), - TelemetryItem(name="test2", time=datetime.now()), - TelemetryItem(name="test3", time=datetime.now()), - ] - - with mock.patch.object(AzureMonitorClient, "track") as mock_track: - mock_track.return_value = TrackResponse( - items_received=3, - items_accepted=2, - errors=[ - TelemetryErrorDetails( - index=2, - status_code=500, - message="Server error", - ), - ], - ) - - result = exporter._transmit(test_envelopes) - - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - - exporter.storage.put.assert_called_once() - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "true", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_transmission_service_request_error_tracks_retry_items(self): - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.collect_customer_sdkstats") as mock_collect: - mock_customer_sdkstats = mock.Mock() - - def mock_collect_side_effect(exporter): - setattr(exporter, '_customer_sdkstats_metrics', mock_customer_sdkstats) - - mock_collect.side_effect = mock_collect_side_effect - - - exporter = BaseExporter(disable_offline_storage=True) - - test_envelopes = [TelemetryItem(name="test1", time=datetime.now())] - - with mock.patch.object(AzureMonitorClient, "track", side_effect=ServiceRequestError("Connection failed")): - result = exporter._transmit(test_envelopes) - - self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - - mock_customer_sdkstats.count_retry_items.assert_called_once() - - args, kwargs = mock_customer_sdkstats.count_retry_items.call_args - self.assertEqual(args[0], 1) - self.assertEqual(args[2], RetryCode.CLIENT_EXCEPTION) - self.assertEqual(args[3], "Connection failed") - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "false", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_transmission_general_exception_tracks_dropped_items(self): - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.collect_customer_sdkstats") as mock_collect: - mock_customer_sdkstats = mock.Mock() - - def mock_collect_side_effect(exporter): - setattr(exporter, '_customer_sdkstats_metrics', mock_customer_sdkstats) - - mock_collect.side_effect = mock_collect_side_effect - - exporter = BaseExporter(disable_offline_storage=True) - - test_envelopes = [TelemetryItem(name="test1", time=datetime.now())] - - with mock.patch.object(AzureMonitorClient, "track", side_effect=Exception("Unexpected error")): - result = exporter._transmit(test_envelopes) - - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - - # We expect two calls: one for storage disabled, one for the exception - expected_calls = [ - mock.call(1, 'UNKNOWN', DropCode.CLIENT_STORAGE_DISABLED), - mock.call(1, 'UNKNOWN', DropCode.CLIENT_EXCEPTION, 'Unexpected error') - ] - mock_customer_sdkstats.count_dropped_items.assert_has_calls(expected_calls) - self.assertEqual(mock_customer_sdkstats.count_dropped_items.call_count, 2) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "false", - }, - ) - def test_constructor_customer_sdkstats_disabled(self): - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.collect_customer_sdkstats") as mock_collect: - exporter = BaseExporter(disable_offline_storage=True) - - mock_collect.assert_not_called() - - self.assertIsNone(exporter._customer_sdkstats_metrics) - - @mock.patch.dict( - os.environ, - { - "APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL": "false", - "APPLICATIONINSIGHTS_SDKSTATS_ENABLED_PREVIEW": "true", - }, - ) - def test_constructor_customer_sdkstats_enabled(self): - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._customer_sdkstats.collect_customer_sdkstats") as mock_collect: - exporter = BaseExporter(disable_offline_storage=True) - - self.assertGreaterEqual(mock_collect.call_count, 1) - - exporter_calls = [call[0][0] for call in mock_collect.call_args_list] - self.assertIn(exporter, exporter_calls) - - self.assertIsNone(exporter._customer_sdkstats_metrics) - - def test_is_customer_sdkstats_exporter_false(self): - exporter = BaseExporter(disable_offline_storage=True) - self.assertFalse(exporter._is_customer_sdkstats_exporter()) - - def test_customer_sdkstats_metrics_initialization_none(self): + def test_determine_client_retry_code_timeout_error(self): exporter = BaseExporter(disable_offline_storage=True) - self.assertIsNone(exporter._customer_sdkstats_metrics) - - # Tests for customer sdkstats tracking in _transmit method - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_retry_items") - def test_transmit_track_retry_items_throttle_error(self, mock_track_retry): - """Test that _track_retry_items is called when 429 (retryable) error occurs.""" - with mock.patch.object(AzureMonitorClient, "track", throw(HttpResponseError, message="throttled", response=MockResponse(429, "{}"))): - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", - disable_offline_storage=True - ) - # Enable customer sdkstats collection - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - test_envelope = mock.Mock() - result = exporter._transmit([test_envelope]) - - self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - mock_track_retry.assert_called_once_with( - exporter._customer_sdkstats_metrics, - [test_envelope], - mock.ANY # HttpResponseError instance - ) - - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") - def test_transmit_track_dropped_items_true_throttle_error(self, mock_track_dropped): - """Test that _track_dropped_items is called when true throttle error (402/439) occurs.""" - with mock.patch.object(AzureMonitorClient, "track", throw(HttpResponseError, message="quota exceeded", response=MockResponse(402, "{}"))): - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", - disable_offline_storage=True - ) - # Enable customer sdkstats collection - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - test_envelope = mock.Mock() - result = exporter._transmit([test_envelope]) - - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - mock_track_dropped.assert_called_once_with( - exporter._customer_sdkstats_metrics, - [test_envelope], - 402 # HTTP status code is passed directly - ) - - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") - def test_transmit_track_dropped_items_general_exception(self, mock_track_dropped): - """Test that _track_dropped_items is called for general exceptions.""" - with mock.patch.object(AzureMonitorClient, "track", throw(Exception, "Generic error")): - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", - disable_offline_storage=True - ) - # Enable customer sdkstats collection - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - test_envelope = mock.Mock() - result = exporter._transmit([test_envelope]) - - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - mock_track_dropped.assert_called_once_with( - exporter._customer_sdkstats_metrics, - [test_envelope], - DropCode.CLIENT_EXCEPTION, - mock.ANY # Exception instance - ) - - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") - def test_transmit_track_dropped_items_non_retryable_http_error(self, mock_track_dropped): - """Test that _track_dropped_items is called for non-retryable HTTP errors.""" - with mock.patch.object(AzureMonitorClient, "track", throw(HttpResponseError, message="bad request", response=MockResponse(400, "{}"))): - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", - disable_offline_storage=True - ) - # Enable customer sdkstats collection - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - test_envelope = mock.Mock() - result = exporter._transmit([test_envelope]) - - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - mock_track_dropped.assert_called_once_with( - exporter._customer_sdkstats_metrics, - [test_envelope], - 400 # HTTP status code is passed directly - ) - - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_retry_items") - def test_transmit_track_retry_items_retryable_http_error(self, mock_track_retry): - """Test that _track_retry_items is called for retryable HTTP errors.""" - with mock.patch.object(AzureMonitorClient, "track", throw(HttpResponseError, message="server error", response=MockResponse(500, "{}"))): - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", - disable_offline_storage=True - ) - # Enable customer sdkstats collection - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - test_envelope = mock.Mock() - result = exporter._transmit([test_envelope]) - - self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - mock_track_retry.assert_called_once_with( - exporter._customer_sdkstats_metrics, - [test_envelope], - mock.ANY # HttpResponseError instance - ) - - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_retry_items") - def test_transmit_track_retry_items_service_request_error(self, mock_track_retry): - """Test that _track_retry_items is called for ServiceRequestError.""" - with mock.patch.object(AzureMonitorClient, "track", throw(ServiceRequestError, message="Request failed")): - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", - disable_offline_storage=True - ) - # Enable customer sdkstats collection - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - test_envelope = mock.Mock() - result = exporter._transmit([test_envelope]) - - self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - mock_track_retry.assert_called_once_with( - exporter._customer_sdkstats_metrics, - [test_envelope], - mock.ANY # ServiceRequestError instance - ) - - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") - def test_transmit_track_dropped_items_redirect_error_no_headers(self, mock_track_dropped): - """Test that _track_dropped_items is called for redirect errors without proper headers.""" - response = MockResponse(302, "{}") - response.headers = {} # No location header - with mock.patch.object(AzureMonitorClient, "track", throw(HttpResponseError, message="redirect", response=response)): - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", - disable_offline_storage=True - ) - # Enable customer sdkstats collection - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - test_envelope = mock.Mock() - result = exporter._transmit([test_envelope]) - - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - mock_track_dropped.assert_called_once_with( - exporter._customer_sdkstats_metrics, - [test_envelope], - 302 # HTTP status code is passed directly - ) - - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") - def test_transmit_track_dropped_items_storage_disabled(self, mock_track_dropped): - """Test that _track_dropped_items is called when storage is disabled for retryable items.""" - with mock.patch.object(AzureMonitorClient, "track") as mock_track: - mock_track.return_value = TrackResponse( - items_received=2, - items_accepted=1, - errors=[ - TelemetryErrorDetails( - index=0, - status_code=500, - message="Internal server error" - ) - ] - ) - - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", - disable_offline_storage=True # Storage disabled - ) - # Enable customer sdkstats collection - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - test_envelope1 = mock.Mock() - test_envelope2 = mock.Mock() - result = exporter._transmit([test_envelope1, test_envelope2]) - - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - mock_track_dropped.assert_called_once_with( - exporter._customer_sdkstats_metrics, - [test_envelope1], # Only the failed envelope - DropCode.CLIENT_STORAGE_DISABLED - ) - - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_retry_items") - def test_transmit_no_tracking_when_customer_sdkstats_disabled(self, mock_track_retry, mock_track_dropped): - """Test that tracking functions are not called when customer sdkstats is disabled.""" - with mock.patch.object(AzureMonitorClient, "track", throw(HttpResponseError, message="server error", response=MockResponse(500, "{}"))): - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", - disable_offline_storage=True - ) - # Disable customer sdkstats collection - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=False) - exporter._customer_sdkstats_metrics = mock.Mock() - - test_envelope = mock.Mock() - result = exporter._transmit([test_envelope]) - - self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - mock_track_retry.assert_not_called() - mock_track_dropped.assert_not_called() - - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_retry_items") - def test_transmit_no_tracking_when_customer_sdkstats_metrics_none(self, mock_track_retry, mock_track_dropped): - """Test that tracking functions are not called when customer sdkstats metrics is None.""" - with mock.patch.object(AzureMonitorClient, "track", throw(HttpResponseError, message="server error", response=MockResponse(500, "{}"))): - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", - disable_offline_storage=True - ) - # customer sdkstats metrics is None - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - exporter._customer_sdkstats_metrics = None - - test_envelope = mock.Mock() - result = exporter._transmit([test_envelope]) - - self.assertEqual(result, ExportResult.FAILED_RETRYABLE) - mock_track_retry.assert_not_called() - mock_track_dropped.assert_not_called() - - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") - def test_transmit_track_dropped_items_partial_failure_non_retryable(self, mock_track_dropped): - """Test that _track_dropped_items is called for non-retryable partial failures.""" - with mock.patch.object(AzureMonitorClient, "track") as mock_track: - mock_track.return_value = TrackResponse( - items_received=2, - items_accepted=1, - errors=[ - TelemetryErrorDetails( - index=0, - status_code=400, # Non-retryable - message="Invalid data" - ) - ] - ) - - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", - disable_offline_storage=True - ) - # Enable customer sdkstats collection - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - test_envelope1 = mock.Mock() - test_envelope2 = mock.Mock() - result = exporter._transmit([test_envelope1, test_envelope2]) - - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - mock_track_dropped.assert_called_once_with( - exporter._customer_sdkstats_metrics, - [test_envelope1], # Only the failed envelope - 400 # HTTP status code is passed directly - ) - - def test_track_dropped_items_no_error(self): - """Test _track_dropped_items with no error (error=None).""" - # Create mock customer sdkstats metrics - mock_customer_sdkstats = mock.Mock() - - # Create test envelopes - envelope1 = TelemetryItem(name="test1", time=datetime.now()) - envelope2 = TelemetryItem(name="test2", time=datetime.now()) - envelopes = [envelope1, envelope2] - - # Mock _get_telemetry_type to return consistent values - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._get_telemetry_type") as mock_get_type: - mock_get_type.side_effect = ["trace", "metric"] - - # Call _track_dropped_items with no error - _track_dropped_items( - mock_customer_sdkstats, - envelopes, - DropCode.CLIENT_STORAGE_DISABLED, - error_message=None - ) - - # Verify that count_dropped_items was called for each envelope - self.assertEqual(mock_customer_sdkstats.count_dropped_items.call_count, 2) - - # Check first call - first_call = mock_customer_sdkstats.count_dropped_items.call_args_list[0] - self.assertEqual(first_call[0], (1, "trace", DropCode.CLIENT_STORAGE_DISABLED)) - - # Check second call - second_call = mock_customer_sdkstats.count_dropped_items.call_args_list[1] - self.assertEqual(second_call[0], (1, "metric", DropCode.CLIENT_STORAGE_DISABLED)) - - def test_track_dropped_items_with_error_index(self): - """Test _track_dropped_items with error string.""" - # Create mock customer sdkstats metrics - mock_customer_sdkstats = mock.Mock() - - # Create test envelopes - envelope1 = TelemetryItem(name="test1", time=datetime.now()) - envelope2 = TelemetryItem(name="test2", time=datetime.now()) - envelope3 = TelemetryItem(name="test3", time=datetime.now()) - envelopes = [envelope1, envelope2, envelope3] - - # Create error string - error_message = "Bad Request: Invalid telemetry data" - - # Mock _get_telemetry_type - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._get_telemetry_type") as mock_get_type: - mock_get_type.side_effect = ["trace", "metric", "log"] - - # Call _track_dropped_items with error string - _track_dropped_items( - mock_customer_sdkstats, - envelopes, - DropCode.CLIENT_EXCEPTION, - error_message=error_message - ) - - # With the current simplified implementation, all envelopes are processed when error is not None - self.assertEqual(mock_customer_sdkstats.count_dropped_items.call_count, 3) - - # Check the calls - calls = mock_customer_sdkstats.count_dropped_items.call_args_list - self.assertEqual(calls[0][0], (1, "trace", DropCode.CLIENT_EXCEPTION, error_message)) - self.assertEqual(calls[1][0], (1, "metric", DropCode.CLIENT_EXCEPTION, error_message)) - self.assertEqual(calls[2][0], (1, "log", DropCode.CLIENT_EXCEPTION, error_message)) - - def test_track_dropped_items_with_client_exception_error(self): - """Test _track_dropped_items with CLIENT_EXCEPTION drop code and error string.""" - # Create mock customer sdkstats metrics - mock_customer_sdkstats = mock.Mock() - - # Create test envelopes - envelope1 = TelemetryItem(name="test1", time=datetime.now()) - envelope2 = TelemetryItem(name="test2", time=datetime.now()) - envelopes = [envelope1, envelope2] - - # Create error string - error_message = "Connection timeout" - - # Mock _get_telemetry_type - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._get_telemetry_type") as mock_get_type: - mock_get_type.side_effect = ["trace", "metric"] - - # Call _track_dropped_items with CLIENT_EXCEPTION - _track_dropped_items( - mock_customer_sdkstats, - envelopes, - DropCode.CLIENT_EXCEPTION, - error_message=error_message - ) - - # Verify that count_dropped_items was called for each envelope - self.assertEqual(mock_customer_sdkstats.count_dropped_items.call_count, 2) - - # Check first call - first_call = mock_customer_sdkstats.count_dropped_items.call_args_list[0] - self.assertEqual(first_call[0], (1, "trace", DropCode.CLIENT_EXCEPTION, error_message)) - - # Check second call - second_call = mock_customer_sdkstats.count_dropped_items.call_args_list[1] - self.assertEqual(second_call[0], (1, "metric", DropCode.CLIENT_EXCEPTION, error_message)) - - def test_track_dropped_items_with_status_code_error_not_client_exception(self): - """Test _track_dropped_items with error string and non-CLIENT_EXCEPTION drop code.""" - # Create mock customer sdkstats metrics - mock_customer_sdkstats = mock.Mock() - - # Create test envelopes - envelope1 = TelemetryItem(name="test1", time=datetime.now()) - envelopes = [envelope1] - - # Create error string - error_message = "Internal Server Error" - - # Mock _get_telemetry_type - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._get_telemetry_type") as mock_get_type: - mock_get_type.return_value = "trace" - - # Call _track_dropped_items with non-CLIENT_EXCEPTION drop code - _track_dropped_items( - mock_customer_sdkstats, - envelopes, - 500, # Using status code as drop code - error_message=error_message - ) - - # With the current simplified implementation, any error (not None) will process all envelopes - mock_customer_sdkstats.count_dropped_items.assert_called_once_with( - 1, "trace", 500, error_message - ) - - def test_track_dropped_items_with_error_none_index(self): - """Test _track_dropped_items with error string.""" - # Create mock customer sdkstats metrics - mock_customer_sdkstats = mock.Mock() - - # Create test envelopes - envelope1 = TelemetryItem(name="test1", time=datetime.now()) - envelopes = [envelope1] - - # Create error string - error_message = "Bad Request" - - # Mock _get_telemetry_type - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._get_telemetry_type") as mock_get_type: - mock_get_type.return_value = "trace" - - # Call _track_dropped_items - _track_dropped_items( - mock_customer_sdkstats, - envelopes, - DropCode.CLIENT_EXCEPTION, - error_message=error_message - ) - - # With current implementation, any non-None error will process all envelopes - mock_customer_sdkstats.count_dropped_items.assert_called_once_with( - 1, "trace", DropCode.CLIENT_EXCEPTION, error_message - ) - - def test_track_dropped_items_no_customer_sdkstats_metrics(self): - """Test _track_dropped_items with None customer_sdkstats_metrics.""" - # Create test envelopes - envelope1 = TelemetryItem(name="test1", time=datetime.now()) - envelopes = [envelope1] - - # Call _track_dropped_items with None metrics - result = _track_dropped_items( - customer_sdkstats_metrics=None, - envelopes=envelopes, - drop_code=DropCode.CLIENT_STORAGE_DISABLED, - error_message=None - ) - - # Should return None and not raise any exceptions - self.assertIsNone(result) - - def test_track_dropped_items_empty_envelopes_list(self): - """Test _track_dropped_items with empty envelopes list.""" - # Create mock customer sdkstats metrics - mock_customer_sdkstats = mock.Mock() - - # Call _track_dropped_items with empty list - _track_dropped_items( - mock_customer_sdkstats, - envelopes=[], - drop_code=DropCode.CLIENT_STORAGE_DISABLED, - error_message=None - ) - - # Should not call count_dropped_items since there are no envelopes - mock_customer_sdkstats.count_dropped_items.assert_not_called() - - def test_track_dropped_items_integration_with_transmit_206_error_status_code(self): - """Integration test for _track_dropped_items with 206 response containing status code errors.""" - with mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") as mock_track_dropped: - with mock.patch.object(AzureMonitorClient, "track") as mock_track: - # Setup 206 response with mixed success/failure - mock_track.return_value = TrackResponse( - items_received=3, - items_accepted=1, - errors=[ - TelemetryErrorDetails( - index=0, - status_code=400, # Non-retryable - should be tracked as dropped - message="Bad request" - ), - TelemetryErrorDetails( - index=2, - status_code=500, # Retryable - should not be tracked as dropped initially - message="Server error" - ) - ] - ) - - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", - disable_offline_storage=True - ) - # Enable customer sdkstats collection - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - test_envelopes = [ - TelemetryItem(name="envelope0", time=datetime.now()), - TelemetryItem(name="envelope1", time=datetime.now()), - TelemetryItem(name="envelope2", time=datetime.now()) - ] - - result = exporter._transmit(test_envelopes) - - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - - # Verify _track_dropped_items was called for the non-retryable error (400) - - # Check if _track_dropped_items was called (regardless of the bug) - self.assertTrue(mock_track_dropped.called) - - # Find the call that matches our expectations - found_call = False - for call in mock_track_dropped.call_args_list: - args = call[0] - if len(args) >= 3 and args[2] == 400: # status_code as drop_code - found_call = True - break - - self.assertTrue(found_call, "Expected call to _track_dropped_items with status_code 400 not found") - - def test_track_dropped_items_with_various_drop_codes(self): - """Test _track_dropped_items with different DropCode values.""" - # Create mock customer sdkstats metrics - mock_customer_sdkstats = mock.Mock() - envelope = TelemetryItem(name="test", time=datetime.now()) - - drop_codes_to_test = [ - DropCode.CLIENT_STORAGE_DISABLED, - DropCode.CLIENT_EXCEPTION, - 400, # HTTP status code - 500, # HTTP status code - ] - - # Mock _get_telemetry_type - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._get_telemetry_type") as mock_get_type: - mock_get_type.return_value = "trace" - - for drop_code in drop_codes_to_test: - mock_customer_sdkstats.reset_mock() - - # Test with no error - _track_dropped_items( - mock_customer_sdkstats, - [envelope], - drop_code, - error_message=None - ) - - # Should call count_dropped_items for each drop_code - mock_customer_sdkstats.count_dropped_items.assert_called_once_with( - 1, "trace", drop_code - ) - - def test_track_dropped_items_with_status_code_as_drop_code_and_error(self): - """Test _track_dropped_items using HTTP status code as drop_code with error string.""" - # Create mock customer sdkstats metrics - mock_customer_sdkstats = mock.Mock() - envelope = TelemetryItem(name="test", time=datetime.now()) - - # Create error string - error_message = "Bad Request" - - # Mock _get_telemetry_type - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._get_telemetry_type") as mock_get_type: - mock_get_type.return_value = "trace" - - # Call _track_dropped_items using status code as drop_code (common pattern in _base.py) - _track_dropped_items( - mock_customer_sdkstats, - [envelope], - drop_code=400, # Status code as drop code - error_message=error_message - ) - - # Should call count_dropped_items with status code as drop_code - mock_customer_sdkstats.count_dropped_items.assert_called_once_with( - 1, "trace", 400, error_message - ) - - def test_track_dropped_items_error_with_string_conversion(self): - """Test _track_dropped_items with different string error types.""" - # Create mock customer sdkstats metrics - mock_customer_sdkstats = mock.Mock() - envelope = TelemetryItem(name="test", time=datetime.now()) - - # Test different error string cases - error_cases = [ - "Test exception", - "Invalid value error", - "Connection timeout", - "Server internal error" - ] - - # Mock _get_telemetry_type - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._get_telemetry_type") as mock_get_type: - mock_get_type.return_value = "trace" - - for error_message in error_cases: - mock_customer_sdkstats.reset_mock() - - _track_dropped_items( - mock_customer_sdkstats, - [envelope], - DropCode.CLIENT_EXCEPTION, - error_message=error_message - ) - - # Should call count_dropped_items with the error string - mock_customer_sdkstats.count_dropped_items.assert_called_once_with( - 1, "trace", DropCode.CLIENT_EXCEPTION, error_message - ) - - def test_track_dropped_items_regression_base_exporter_pattern(self): - """Regression test that matches the actual usage pattern in _base.py.""" - # This test verifies the common pattern used in _base.py where: - # 1. Status codes are used as drop codes - # 2. Error messages are passed as strings - # 3. Single envelope is wrapped in a list (fixing the bug in _base.py) - - mock_customer_sdkstats = mock.Mock() - envelope = TelemetryItem(name="test", time=datetime.now()) - - # Create error message string (what should be passed instead of error object) - error_message = "Bad Request - Invalid telemetry" - - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._get_telemetry_type") as mock_get_type: - mock_get_type.return_value = "trace" - - # Test the corrected pattern (envelope wrapped in list, error as string) - _track_dropped_items( - mock_customer_sdkstats, - [envelope], # Correct - envelope wrapped in list - drop_code=400, # Status code as drop code - error_message=error_message # Error as string - ) - - mock_customer_sdkstats.count_dropped_items.assert_called_once_with( - 1, "trace", 400, error_message - ) - - # Test what would happen with the bug (single envelope instead of list) - mock_customer_sdkstats.reset_mock() - mock_get_type.reset_mock() - - # This would cause an error in real usage since envelope is not iterable - with self.assertRaises(TypeError): - _track_dropped_items( - mock_customer_sdkstats, - envelope, # Bug - single envelope instead of list - drop_code=400, - error_message=error_message - ) - - def test_track_dropped_items_custom_message_circular_redirect_scenario(self): - """Test _track_dropped_items with custom message for circular redirect scenario.""" - # This test simulates the circular redirect scenario in _base.py lines 336-349 - # to verify that the custom error message is properly passed through - - mock_customer_sdkstats = mock.Mock() - envelope = TelemetryItem(name="test_request", time=datetime.now()) - - # Use the exact custom message from the _base.py code - expected_custom_message = "Error sending telemetry because of circular redirects. Please check the integrity of your connection string." - - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._get_telemetry_type") as mock_get_type: - mock_get_type.return_value = "request" - - # Call _track_dropped_items with the exact custom message from the circular redirect scenario - _track_dropped_items( - mock_customer_sdkstats, - [envelope], - drop_code=DropCode.CLIENT_EXCEPTION, - error_message=expected_custom_message - ) - - # Verify the custom message is properly passed through to count_dropped_items - mock_customer_sdkstats.count_dropped_items.assert_called_once_with( - 1, "request", DropCode.CLIENT_EXCEPTION, expected_custom_message - ) - - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") - def test_transmit_circular_redirect_scenario_integration(self, mock_track_dropped): - """Integration test that simulates the circular redirect scenario to verify custom message.""" - # This test simulates the actual circular redirect scenario that triggers the custom message - - # Create a redirect response with correct redirect status code (307 or 308) - redirect_response = MockResponse(307, "{}") # Use 307 which is in _REDIRECT_STATUS_CODES - redirect_response.headers = {"location": "https://redirect.example.com"} - - with mock.patch.object(AzureMonitorClient, "track") as mock_track: - # Mock the track method to raise HttpResponseError with redirect status - mock_track.side_effect = HttpResponseError( - message="Temporary Redirect", - response=redirect_response - ) - - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", - disable_offline_storage=True - ) - - # Enable customer sdkstats collection - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - # Set consecutive redirects to one less than max to simulate we've been redirecting - # When the HttpResponseError is raised, _consecutive_redirects will be incremented - # and then compared to max_redirects. We want it to equal max_redirects after increment. - max_redirects = exporter.client._config.redirect_policy.max_redirects - exporter._consecutive_redirects = max_redirects - 1 # Will become max_redirects after increment - - test_envelope = TelemetryItem(name="test", time=datetime.now()) - result = exporter._transmit([test_envelope]) - - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - - # Verify _track_dropped_items was called with the circular redirect custom message - mock_track_dropped.assert_called_with( - exporter._customer_sdkstats_metrics, - [test_envelope], - DropCode.CLIENT_EXCEPTION, - "Error sending telemetry because of circular redirects. Please check the integrity of your connection string." - ) - - @mock.patch("azure.monitor.opentelemetry.exporter.export._base._track_dropped_items") - def test_transmit_redirect_parsing_error_scenario_integration(self, mock_track_dropped): - """Integration test that simulates the redirect parsing error scenario to verify custom message.""" - # This test simulates the scenario where redirect headers are missing/malformed (lines 328-335) - - # Create a redirect response with NO headers (or empty headers) - redirect_response = MockResponse(307, "{}") # Use 307 which is in _REDIRECT_STATUS_CODES - redirect_response.headers = {} # Empty headers - will cause parsing error - - with mock.patch.object(AzureMonitorClient, "track") as mock_track: - # Mock the track method to raise HttpResponseError with redirect status but no location header - mock_track.side_effect = HttpResponseError( - message="Temporary Redirect", - response=redirect_response - ) - - exporter = BaseExporter( - connection_string="InstrumentationKey=12345678-1234-5678-abcd-12345678abcd", - disable_offline_storage=True - ) - - # Enable customer sdkstats collection - exporter._should_collect_customer_sdkstats = mock.Mock(return_value=True) - exporter._customer_sdkstats_metrics = mock.Mock() - - # Set consecutive redirects to be less than max to ensure we go into the redirect handling logic - # but not the circular redirect scenario - max_redirects = exporter.client._config.redirect_policy.max_redirects - exporter._consecutive_redirects = 0 # Start with 0 redirects - - test_envelope = TelemetryItem(name="test", time=datetime.now()) - result = exporter._transmit([test_envelope]) - - self.assertEqual(result, ExportResult.FAILED_NOT_RETRYABLE) - - # Verify _track_dropped_items was called with the redirect parsing error custom message - mock_track_dropped.assert_called_with( - exporter._customer_sdkstats_metrics, - [test_envelope], - DropCode.CLIENT_EXCEPTION, - "Error parsing redirect information." - ) - - def test_track_dropped_items_custom_message_vs_no_message_comparison(self): - """Test _track_dropped_items comparing custom message vs no message scenarios.""" - # This test demonstrates the difference between providing a custom message - # and not providing any error message - - mock_customer_sdkstats = mock.Mock() - envelope = TelemetryItem(name="test_trace", time=datetime.now()) - - with mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._utils._get_telemetry_type") as mock_get_type: - mock_get_type.return_value = "trace" - - # Test 1: Call with custom message - custom_message = "Custom error description for debugging" - _track_dropped_items( - mock_customer_sdkstats, - [envelope], - drop_code=DropCode.CLIENT_EXCEPTION, - error_message=custom_message - ) - - # Verify custom message is included - mock_customer_sdkstats.count_dropped_items.assert_called_with( - 1, "trace", DropCode.CLIENT_EXCEPTION, custom_message - ) - - # Reset mock for second test - mock_customer_sdkstats.reset_mock() - - # Test 2: Call without error message (default None) - _track_dropped_items( - mock_customer_sdkstats, - [envelope], - drop_code=DropCode.CLIENT_EXCEPTION - # error parameter omitted, should default to None - ) - - # Verify no error message is passed (only 3 arguments) - mock_customer_sdkstats.count_dropped_items.assert_called_with( - 1, "trace", DropCode.CLIENT_EXCEPTION - ) - - # Verify the calls were different - self.assertEqual(mock_customer_sdkstats.count_dropped_items.call_count, 1) - - # Check that the second call didn't include the error message - args, kwargs = mock_customer_sdkstats.count_dropped_items.call_args - self.assertEqual(len(args), 3) # Should only have 3 args when no error message def validate_telemetry_item(item1, item2):