Skip to content

Commit d6f1a72

Browse files
authored
Read for global feature sdkstats bitmap and add a 15 second delay timer (#47031)
* Read from global state and add a 15s warmup delay * Add CHANGELOG * Address feedback * Fix format issue * Read from global state
1 parent 84ae321 commit d6f1a72

8 files changed

Lines changed: 158 additions & 10 deletions

File tree

sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## 1.0.0b53 (Unreleased)
44

55
### Features Added
6+
- Read for global feature sdkstats bitmap and add a 15 second delay timer
7+
([#47031](https://github.com/Azure/azure-sdk-for-python/pull/47031))
68

79
### Breaking Changes
810

sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_storage.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,8 +264,8 @@ def _check_and_set_folder_permissions(self) -> bool:
264264
# Unix
265265
else:
266266
open_flags = (
267-
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW # cspell:disable-line
268-
) # pylint: disable=no-member
267+
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW # pylint: disable=no-member # cspell:disable-line
268+
)
269269
dir_fd = os.open(self._path, open_flags)
270270
try:
271271
dir_stat = os.fstat(dir_fd)

sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_manager.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424

2525
logger = logging.getLogger(__name__)
2626

27+
_STATSBEAT_INITIAL_EXPORT_WARMUP_SECONDS = 15 # 15 second warmup delay
28+
2729

2830
class StatsbeatConfig:
2931
"""Configuration class for Statsbeat metrics collection."""
@@ -154,6 +156,7 @@ def __init__(self) -> None:
154156
self._initialized: bool = False # type: ignore
155157
self._metrics: Optional[_StatsbeatMetrics] = None # type: ignore
156158
self._meter_provider: Optional[MeterProvider] = None # type: ignore
159+
self._warmup_timer: Optional[threading.Timer] = None
157160

158161
# Set during first initialization, preserved in shutdown for potential re-initialization
159162
self._config: Optional[StatsbeatConfig] = None # type: ignore
@@ -241,8 +244,8 @@ def _do_initialize(self, config: StatsbeatConfig) -> bool:
241244
config.distro_version,
242245
)
243246

244-
# Force initial flush and initialize non-initial metrics
245-
self._meter_provider.force_flush()
247+
# Schedule initial statsbeat flush after warmup delay to allow feature bits to settle.
248+
self._schedule_initial_export_flush()
246249
self._metrics.init_non_initial_metrics()
247250

248251
self._config = config
@@ -258,8 +261,28 @@ def _do_initialize(self, config: StatsbeatConfig) -> bool:
258261
self._cleanup()
259262
return False
260263

264+
def _schedule_initial_export_flush(self) -> None:
265+
def _flush() -> None:
266+
meter_provider = self._meter_provider
267+
if not self._initialized or meter_provider is None:
268+
return
269+
try:
270+
meter_provider.force_flush()
271+
except Exception as e: # pylint: disable=broad-except
272+
logger.warning( # pylint: disable=do-not-log-exceptions-if-not-debug
273+
"Failed to force flush statsbeat after warmup: %s", e
274+
)
275+
276+
timer = threading.Timer(_STATSBEAT_INITIAL_EXPORT_WARMUP_SECONDS, _flush)
277+
timer.daemon = True
278+
self._warmup_timer = timer
279+
timer.start()
280+
261281
def _cleanup(self, shutdown_meter_provider: bool = True) -> None:
262282
# Clean up resources with optional meter provider shutdown
283+
if hasattr(self, "_warmup_timer") and self._warmup_timer:
284+
self._warmup_timer.cancel()
285+
self._warmup_timer = None
263286
if shutdown_meter_provider and self._meter_provider:
264287
try:
265288
self._meter_provider.shutdown()

sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_state.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"LIVE_METRICS_FEATURE_SET": False,
2121
"CUSTOMER_SDKSTATS_FEATURE_SET": False,
2222
"BROWSER_SDK_LOADER_FEATURE_SET": False,
23+
"FEATURE_ATTRIBUTE_BITS": 0,
2324
}
2425
_STATSBEAT_STATE_LOCK = threading.Lock()
2526
_STATSBEAT_FAILURE_COUNT_THRESHOLD = 3
@@ -115,3 +116,12 @@ def get_statsbeat_browser_sdk_loader_feature_set(): # pylint: disable=name-too-
115116
def set_statsbeat_browser_sdk_loader_feature_set(): # pylint: disable=name-too-long
116117
with _STATSBEAT_STATE_LOCK:
117118
_STATSBEAT_STATE["BROWSER_SDK_LOADER_FEATURE_SET"] = True
119+
120+
121+
def get_statsbeat_feature_attribute_bits() -> int:
122+
return int(_STATSBEAT_STATE["FEATURE_ATTRIBUTE_BITS"])
123+
124+
125+
def set_statsbeat_feature_attribute_bits(feature_bits: int) -> None:
126+
with _STATSBEAT_STATE_LOCK:
127+
_STATSBEAT_STATE["FEATURE_ATTRIBUTE_BITS"] = int(feature_bits)

sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/statsbeat/_statsbeat_metrics.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
from azure.monitor.opentelemetry.exporter.statsbeat._state import (
3434
_REQUESTS_MAP_LOCK,
3535
_REQUESTS_MAP,
36+
get_statsbeat_feature_attribute_bits,
37+
set_statsbeat_feature_attribute_bits,
3638
get_statsbeat_live_metrics_feature_set,
3739
get_statsbeat_custom_events_feature_set,
3840
get_statsbeat_customer_sdkstats_feature_set,
@@ -133,7 +135,9 @@ def __init__(
133135
_StatsbeatMetrics._COMMON_ATTRIBUTES["version"] = _get_version()
134136

135137
self._ikey = instrumentation_key
136-
self._feature = _StatsbeatFeature.NONE
138+
if _StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] is not None:
139+
set_statsbeat_feature_attribute_bits(_StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"])
140+
self._feature = get_statsbeat_feature_attribute_bits()
137141
if not disable_offline_storage:
138142
self._feature |= _StatsbeatFeature.DISK_RETRY
139143
if has_credential:
@@ -166,6 +170,7 @@ def __init__(
166170

167171
_StatsbeatMetrics._NETWORK_ATTRIBUTES["host"] = _shorten_host(endpoint)
168172
_StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] = self._feature
173+
set_statsbeat_feature_attribute_bits(self._feature)
169174
_StatsbeatMetrics._INSTRUMENTATION_ATTRIBUTES["feature"] = _utils.get_instrumentations()
170175

171176
self._vm_retry = True # True if we want to attempt to find if in VM
@@ -264,18 +269,29 @@ def _get_feature_metric(self, options: CallbackOptions) -> Iterable[Observation]
264269
return observations
265270
# Feature metric
266271
# Check if any features were enabled during runtime
272+
if _StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] is not None:
273+
set_statsbeat_feature_attribute_bits(_StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"])
274+
feature_bits = get_statsbeat_feature_attribute_bits()
275+
if feature_bits:
276+
self._feature |= feature_bits
277+
_StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] = self._feature
278+
set_statsbeat_feature_attribute_bits(self._feature)
267279
if get_statsbeat_custom_events_feature_set():
268280
self._feature |= _StatsbeatFeature.CUSTOM_EVENTS_EXTENSION
269281
_StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] = self._feature
282+
set_statsbeat_feature_attribute_bits(self._feature)
270283
if get_statsbeat_live_metrics_feature_set():
271284
self._feature |= _StatsbeatFeature.LIVE_METRICS
272285
_StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] = self._feature
286+
set_statsbeat_feature_attribute_bits(self._feature)
273287
if get_statsbeat_customer_sdkstats_feature_set():
274288
self._feature |= _StatsbeatFeature.CUSTOMER_SDKSTATS
275289
_StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] = self._feature
290+
set_statsbeat_feature_attribute_bits(self._feature)
276291
if get_statsbeat_browser_sdk_loader_feature_set():
277292
self._feature |= _StatsbeatFeature.BROWSER_SDK_LOADER
278293
_StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] = self._feature
294+
set_statsbeat_feature_attribute_bits(self._feature)
279295

280296
# Don't send observation if no features enabled
281297
if self._feature is not _StatsbeatFeature.NONE:

sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_manager.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ def setUp(self):
270270
self.manager._metrics = None
271271
if hasattr(self.manager, "_meter_provider"):
272272
self.manager._meter_provider = None
273+
if hasattr(self.manager, "_warmup_timer"):
274+
self.manager._warmup_timer = None
273275

274276
def tearDown(self):
275277
"""Clean up after tests."""
@@ -366,9 +368,16 @@ def test_initialize_invalid_config(self, mock_is_enabled):
366368
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader")
367369
@patch("azure.monitor.opentelemetry.exporter.export.metrics._exporter.AzureMonitorMetricExporter")
368370
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager._StatsbeatMetrics")
371+
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.threading.Timer")
369372
@patch("azure.monitor.opentelemetry.exporter.statsbeat._state.is_statsbeat_enabled")
370373
def test_initialize_success(
371-
self, mock_is_enabled, mock_statsbeat_metrics, mock_exporter_class, mock_reader_class, mock_meter_provider_class
374+
self,
375+
mock_is_enabled,
376+
mock_timer_class,
377+
mock_statsbeat_metrics,
378+
mock_exporter_class,
379+
mock_reader_class,
380+
mock_meter_provider_class,
372381
):
373382
"""Test successful initialization."""
374383
mock_is_enabled.return_value = True
@@ -388,6 +397,7 @@ def test_initialize_success(
388397
# Mock the statsbeat metrics
389398
mock_metrics = Mock()
390399
mock_statsbeat_metrics.return_value = mock_metrics
400+
mock_timer_class.return_value = Mock()
391401

392402
config = self._create_valid_config()
393403

@@ -404,9 +414,47 @@ def test_initialize_success(
404414
mock_reader_class.assert_called_once()
405415
mock_meter_provider_class.assert_called_once()
406416
mock_statsbeat_metrics.assert_called_once()
407-
mock_meter_provider.force_flush.assert_called_once()
417+
mock_timer_class.assert_called_once()
418+
mock_timer_class.return_value.start.assert_called_once()
419+
mock_meter_provider.force_flush.assert_not_called()
408420
mock_metrics.init_non_initial_metrics.assert_called_once()
409421

422+
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.threading.Timer")
423+
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.MeterProvider")
424+
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader")
425+
@patch("azure.monitor.opentelemetry.exporter.export.metrics._exporter.AzureMonitorMetricExporter")
426+
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager._StatsbeatMetrics")
427+
@patch("azure.monitor.opentelemetry.exporter.statsbeat._state.is_statsbeat_enabled")
428+
def test_initialize_with_delay_schedules_non_blocking_flush(
429+
self,
430+
mock_is_enabled,
431+
mock_statsbeat_metrics,
432+
mock_exporter_class,
433+
mock_reader_class,
434+
mock_meter_provider_class,
435+
mock_timer_class,
436+
):
437+
"""Test delayed initial statsbeat flush is scheduled asynchronously."""
438+
mock_is_enabled.return_value = True
439+
440+
mock_exporter_class.return_value = Mock()
441+
mock_reader_class.return_value = Mock()
442+
mock_meter_provider = Mock()
443+
mock_meter_provider_class.return_value = mock_meter_provider
444+
mock_metrics = Mock()
445+
mock_statsbeat_metrics.return_value = mock_metrics
446+
447+
mock_timer = Mock()
448+
mock_timer_class.return_value = mock_timer
449+
450+
result = self.manager.initialize(self._create_valid_config())
451+
452+
self.assertTrue(result)
453+
mock_timer_class.assert_called_once()
454+
self.assertEqual(mock_timer_class.call_args[0][0], 15)
455+
mock_timer.start.assert_called_once()
456+
mock_meter_provider.force_flush.assert_not_called()
457+
410458
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.MeterProvider")
411459
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader")
412460
@patch("azure.monitor.opentelemetry.exporter.export.metrics._exporter.AzureMonitorMetricExporter")
@@ -627,6 +675,8 @@ def test_cleanup_with_shutdown(self):
627675
self.manager._initialized = True
628676
mock_meter_provider = Mock()
629677
self.manager._meter_provider = mock_meter_provider
678+
mock_timer = Mock()
679+
self.manager._warmup_timer = mock_timer
630680
self.manager._metrics = Mock()
631681
config_mock = Mock()
632682
self.manager._config = config_mock
@@ -636,8 +686,10 @@ def test_cleanup_with_shutdown(self):
636686
self.assertFalse(self.manager._initialized)
637687
self.assertIsNone(self.manager._meter_provider)
638688
self.assertIsNone(self.manager._metrics)
689+
self.assertIsNone(self.manager._warmup_timer)
639690
# Config is intact for potential re-initialization
640691
self.assertEqual(self.manager._config, config_mock)
692+
mock_timer.cancel.assert_called_once()
641693
mock_meter_provider.shutdown.assert_called_once()
642694

643695
def test_cleanup_without_shutdown(self):
@@ -646,6 +698,8 @@ def test_cleanup_without_shutdown(self):
646698
self.manager._initialized = True
647699
mock_meter_provider = Mock()
648700
self.manager._meter_provider = mock_meter_provider
701+
mock_timer = Mock()
702+
self.manager._warmup_timer = mock_timer
649703
self.manager._metrics = Mock()
650704
config_mock = Mock()
651705
self.manager._config = config_mock
@@ -655,8 +709,10 @@ def test_cleanup_without_shutdown(self):
655709
self.assertFalse(self.manager._initialized)
656710
self.assertIsNone(self.manager._meter_provider)
657711
self.assertIsNone(self.manager._metrics)
712+
self.assertIsNone(self.manager._warmup_timer)
658713
# Config is intact for potential re-initialization
659714
self.assertEqual(self.manager._config, config_mock)
715+
mock_timer.cancel.assert_called_once()
660716
mock_meter_provider.shutdown.assert_not_called()
661717

662718
def test_cleanup_meter_provider_exception(self):

sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
_REQUESTS_MAP,
2525
_STATSBEAT_STATE,
2626
_STATSBEAT_STATE_LOCK,
27+
get_statsbeat_feature_attribute_bits,
28+
set_statsbeat_feature_attribute_bits,
2729
)
2830
from azure.monitor.opentelemetry.exporter.statsbeat._statsbeat_metrics import (
2931
_shorten_host,
@@ -87,6 +89,7 @@ def setUp(self):
8789
_STATSBEAT_STATE["CUSTOM_EVENTS_FEATURE_SET"] = False
8890
_STATSBEAT_STATE["LIVE_METRICS_FEATURE_SET"] = False
8991
_STATSBEAT_STATE["CUSTOMER_SDKSTATS_FEATURE_SET"] = False
92+
_STATSBEAT_STATE["FEATURE_ATTRIBUTE_BITS"] = 0
9093

9194
_StatsbeatMetrics._COMMON_ATTRIBUTES = dict(_StatsbeatMetrics_COMMON_ATTRS)
9295
_StatsbeatMetrics._NETWORK_ATTRIBUTES = dict(_StatsbeatMetrics_NETWORK_ATTRS)
@@ -126,6 +129,14 @@ def test_statsbeat_metric_init(self):
126129
self.assertEqual(metric._attach_metric.name, _ATTACH_METRIC_NAME[0])
127130
self.assertEqual(metric._feature_metric.name, _FEATURE_METRIC_NAME[0])
128131

132+
def test_statsbeat_feature_attribute_bits_getter_default(self):
133+
self.assertEqual(get_statsbeat_feature_attribute_bits(), 0)
134+
135+
def test_statsbeat_feature_attribute_bits_setter_and_getter(self):
136+
feature_bits = _StatsbeatFeature.DISK_RETRY | _StatsbeatFeature.LIVE_METRICS
137+
set_statsbeat_feature_attribute_bits(feature_bits)
138+
self.assertEqual(get_statsbeat_feature_attribute_bits(), feature_bits)
139+
129140
@mock.patch("azure.monitor.opentelemetry.exporter._utils._is_attach_enabled")
130141
def test_statsbeat_metric_init_attach_enabled(self, attach_mock):
131142
mp = MeterProvider()
@@ -716,6 +727,8 @@ def test_get_feature_metric_instrumentation(self):
716727
False,
717728
)
718729
metric._feature = _StatsbeatFeature.NONE
730+
_StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] = _StatsbeatFeature.NONE
731+
set_statsbeat_feature_attribute_bits(_StatsbeatFeature.NONE)
719732
attributes = dict(_StatsbeatMetrics._COMMON_ATTRIBUTES)
720733
attributes.update(_StatsbeatMetrics._INSTRUMENTATION_ATTRIBUTES)
721734
self.assertEqual(attributes["type"], _FEATURE_TYPES.INSTRUMENTATION)
@@ -747,6 +760,8 @@ def test_get_feature_metric_instrumentation_none(self):
747760
False,
748761
)
749762
metric._feature = _StatsbeatFeature.NONE
763+
_StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] = _StatsbeatFeature.NONE
764+
set_statsbeat_feature_attribute_bits(_StatsbeatFeature.NONE)
750765
self.assertEqual(_StatsbeatMetrics._INSTRUMENTATION_ATTRIBUTES["feature"], 0)
751766
with mock.patch("azure.monitor.opentelemetry.exporter._utils.get_instrumentations") as instrumentations:
752767
instrumentations.return_value = 0

0 commit comments

Comments
 (0)