From 61c86e136d0eb77130bd89031789d8c33ab5e64a Mon Sep 17 00:00:00 2001 From: Jeremy Voss Date: Thu, 17 Sep 2026 13:19:05 -0700 Subject: [PATCH 1/2] Use OneSettings for SDKStats routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 95d6baa0-65cb-417d-86e5-cbe7a19a30da --- .../CHANGELOG.md | 2 ++ .../exporter/statsbeat/_statsbeat.py | 26 +++++++++--------- .../tests/statsbeat/test_statsbeat.py | 27 +++++++++++++++++++ 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md b/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md index a0e7f0db5e3c..f1fd653e39d0 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md @@ -14,6 +14,8 @@ ### Other Changes +- Preserve the built-in Breeze SDKStats route when the OneSettings feature key is unavailable or invalid, while still applying OneSettings connection-string updates when present. + ## 1.0.0b57 (2026-09-02) ### Features Added 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 4aa8b2eec9a4..86de2ba69baa 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 @@ -46,19 +46,21 @@ def get_statsbeat_configuration_callback(settings: Dict[str, str]): # Check if SDK stats should be enabled based on configuration sdk_stats_enabled = evaluate_feature(_ONE_SETTINGS_FEATURE_SDK_STATS, settings) - if sdk_stats_enabled: - current_config = manager.get_current_config() - # Since config is preserved between shutdowns, - # It will only be None if never initialized - if not current_config: - return - # Get updated config from settings - updated_config = StatsbeatConfig.from_config(current_config, settings) - if updated_config: - manager.initialize(updated_config) - else: - # Disable statsbeat + if sdk_stats_enabled is False: + # Only an explicit OneSettings disable overrides the built-in enabled default. manager.shutdown() + return + + current_config = manager.get_current_config() + # Since config is preserved between shutdowns, + # It will only be None if never initialized + if not current_config: + return + # Get updated config from settings. Missing or invalid endpoint configuration falls back + # to the current built-in Breeze connection string in StatsbeatConfig.from_config. + updated_config = StatsbeatConfig.from_config(current_config, settings) + if updated_config: + manager.initialize(updated_config) def shutdown_statsbeat_metrics() -> bool: diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_statsbeat.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_statsbeat.py index 4f7e38b8ee67..6411cbedf0d7 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_statsbeat.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_statsbeat.py @@ -250,6 +250,33 @@ def test_get_statsbeat_configuration_callback_disable_sdkstats( mock_manager_instance.initialize.assert_not_called() mock_manager_instance.shutdown.assert_called_once() + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._statsbeat.evaluate_feature") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._statsbeat.get_statsbeat_manager") + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._statsbeat.StatsbeatConfig") + def test_get_statsbeat_configuration_callback_missing_feature_uses_default( + self, mock_statsbeat_config_cls, mock_get_manager, mock_evaluate_feature + ): + """Test that missing feature configuration preserves SDKStats and applies endpoint settings.""" + mock_manager_instance = mock.Mock() + mock_get_manager.return_value = mock_manager_instance + current_config = mock.Mock() + mock_manager_instance.get_current_config.return_value = current_config + updated_config = mock.Mock() + mock_statsbeat_config_cls.from_config.return_value = updated_config + mock_evaluate_feature.return_value = None + settings = { + "DEFAULT_STATS_CONNECTION_STRING": ( + "InstrumentationKey=4321abcd-5678-4efa-8abc-1234567890ab;" + "IngestionEndpoint=https://stats.example.com/" + ) + } + + _statsbeat.get_statsbeat_configuration_callback(settings) + + mock_statsbeat_config_cls.from_config.assert_called_once_with(current_config, settings) + mock_manager_instance.initialize.assert_called_once_with(updated_config) + mock_manager_instance.shutdown.assert_not_called() + @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._statsbeat.evaluate_feature") @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._statsbeat.get_statsbeat_manager") @mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._statsbeat.StatsbeatConfig") From d2a5fe308d30a71ebbe6ff66ba1c0b8b0431b534 Mon Sep 17 00:00:00 2001 From: Jeremy Voss Date: Thu, 17 Sep 2026 14:15:29 -0700 Subject: [PATCH 2/2] Use OneSettings SDKStats endpoint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 95d6baa0-65cb-417d-86e5-cbe7a19a30da --- .../opentelemetry/exporter/_constants.py | 1 + .../exporter/statsbeat/_utils.py | 37 +++++++++++++++++-- .../tests/statsbeat/test_statsbeat.py | 34 +++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) 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 e3754df96f7e..1d009aa0222f 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 @@ -117,6 +117,7 @@ ## ONE SETTINGS CONFIGS _ONE_SETTINGS_DEFAULT_STATS_CONNECTION_STRING_KEY = "DEFAULT_STATS_CONNECTION_STRING" +_ONE_SETTINGS_DEFAULT_SDK_STATS_ENDPOINT_KEY = "DEFAULT_SDK_STATS_ENDPOINT" _ONE_SETTINGS_SUPPORTED_DATA_BOUNDARIES_KEY = "SUPPORTED_DATA_BOUNDARIES" _ONE_SETTINGS_FEATURE_LOCAL_STORAGE = "FEATURE_LOCAL_STORAGE" _ONE_SETTINGS_FEATURE_LIVE_METRICS = "FEATURE_LIVE_METRICS" 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 1feb90359d54..e02553a6c08a 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 @@ -3,8 +3,10 @@ import os import logging import json +import re from collections.abc import Iterable # pylint: disable=import-error from typing import Optional, Dict, List +from urllib.parse import urlparse from opentelemetry.metrics import CallbackOptions, Observation from azure.monitor.opentelemetry.exporter._constants import ( @@ -19,8 +21,10 @@ _REQ_DURATION_NAME, _REQ_SUCCESS_NAME, _ONE_SETTINGS_DEFAULT_STATS_CONNECTION_STRING_KEY, + _ONE_SETTINGS_DEFAULT_SDK_STATS_ENDPOINT_KEY, _ONE_SETTINGS_SUPPORTED_DATA_BOUNDARIES_KEY, ) +from azure.monitor.opentelemetry.exporter._connection_string_parser import ConnectionStringParser from azure.monitor.opentelemetry.exporter.statsbeat._state import ( _REQUESTS_MAP, @@ -108,13 +112,14 @@ def _get_connection_string_for_region_from_config(target_region: str, settings: logger = logging.getLogger(__name__) default_connection_string = settings.get(_ONE_SETTINGS_DEFAULT_STATS_CONNECTION_STRING_KEY) + default_endpoint = settings.get(_ONE_SETTINGS_DEFAULT_SDK_STATS_ENDPOINT_KEY) try: # Get supported data boundaries supported_boundaries = settings.get(_ONE_SETTINGS_SUPPORTED_DATA_BOUNDARIES_KEY) if not supported_boundaries: logger.warning("Supported data boundaries key not found in configuration") - return default_connection_string + return _apply_sdk_stats_endpoint(default_connection_string, default_endpoint) # Parse if it's a JSON string if isinstance(supported_boundaries, str): @@ -123,7 +128,7 @@ def _get_connection_string_for_region_from_config(target_region: str, settings: # supported_boundaries should be a list if not isinstance(supported_boundaries, Iterable): logger.warning("Supported data boundaries is not iterable") - return default_connection_string + return _apply_sdk_stats_endpoint(default_connection_string, default_endpoint) # Check each supported boundary to find the region for boundary in supported_boundaries: @@ -147,7 +152,8 @@ def _get_connection_string_for_region_from_config(target_region: str, settings: connection_string = settings.get(connection_string_key) if connection_string: - return connection_string + endpoint = settings.get(f"{boundary}_SDK_STATS_ENDPOINT") or default_endpoint + return _apply_sdk_stats_endpoint(connection_string, endpoint) logger.warning("Connection string key '%s' not found in configuration", connection_string_key) @@ -155,7 +161,7 @@ def _get_connection_string_for_region_from_config(target_region: str, settings: if not default_connection_string: logger.warning("Default stats connection string not found in configuration") return None - return default_connection_string + return _apply_sdk_stats_endpoint(default_connection_string, default_endpoint) except (ValueError, TypeError, KeyError) as ex: logger.warning( # pylint: disable=do-not-log-exceptions-if-not-debug "Error parsing configuration for region '%s': %s", target_region, str(ex) @@ -168,6 +174,29 @@ def _get_connection_string_for_region_from_config(target_region: str, settings: return None +def _apply_sdk_stats_endpoint(connection_string: Optional[str], endpoint: Optional[str]) -> Optional[str]: + if not connection_string or not endpoint: + return connection_string + + try: + parsed_endpoint = urlparse(endpoint) + if parsed_endpoint.scheme.lower() != "https" or not parsed_endpoint.netloc: + return connection_string + ConnectionStringParser(connection_string) + except ValueError: + return connection_string + + normalized_endpoint = endpoint.rstrip("/") + "/" + if re.search(r"(?:^|;)IngestionEndpoint=", connection_string, re.IGNORECASE): + return re.sub( + r"(?i)(IngestionEndpoint=)[^;]*", + rf"\g<1>{normalized_endpoint}", + connection_string, + count=1, + ) + return f"{connection_string.rstrip(';')};IngestionEndpoint={normalized_endpoint}" + + def _get_additional_observations(metric_name: str, options: CallbackOptions) -> List[Observation]: """Return observations contributed by extra callbacks registered on :class:`StatsbeatManager`. diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_statsbeat.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_statsbeat.py index 6411cbedf0d7..c3ae03fab61f 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_statsbeat.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_statsbeat.py @@ -10,6 +10,7 @@ ) from azure.monitor.opentelemetry.exporter.statsbeat import StatsbeatConfig, _statsbeat from azure.monitor.opentelemetry.exporter.statsbeat._manager import StatsbeatManager +from azure.monitor.opentelemetry.exporter.statsbeat._utils import _get_connection_string_for_region_from_config from azure.monitor.opentelemetry.exporter.statsbeat._statsbeat_metrics import _StatsbeatFeature, _StatsbeatMetrics from azure.monitor.opentelemetry.exporter.statsbeat._state import ( _STATSBEAT_STATE, @@ -56,6 +57,39 @@ def tearDown(self): if StatsbeatManager in StatsbeatManager._instances: del StatsbeatManager._instances[StatsbeatManager] + def test_one_settings_endpoint_overrides_connection_string_endpoint(self): + settings = { + "SUPPORTED_DATA_BOUNDARIES": '["EU"]', + "EU_REGIONS": '["westeurope"]', + "EU_STATS_CONNECTION_STRING": ( + "InstrumentationKey=11111111-1111-1111-1111-111111111111;" + "IngestionEndpoint=https://eu.stats.example.com/" + ), + "EU_SDK_STATS_ENDPOINT": "https://eu.collector.example.com/", + } + + result = _get_connection_string_for_region_from_config("westeurope", settings) + + self.assertEqual( + result, + "InstrumentationKey=11111111-1111-1111-1111-111111111111;" + "IngestionEndpoint=https://eu.collector.example.com/", + ) + + def test_invalid_one_settings_endpoint_preserves_connection_string_endpoint(self): + connection_string = ( + "InstrumentationKey=11111111-1111-1111-1111-111111111111;" + "IngestionEndpoint=https://default.stats.example.com/" + ) + settings = { + "DEFAULT_STATS_CONNECTION_STRING": connection_string, + "DEFAULT_SDK_STATS_ENDPOINT": "not-a-url", + } + + result = _get_connection_string_for_region_from_config("westus", settings) + + self.assertEqual(result, 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")