Skip to content

Commit 4297e17

Browse files
committed
Read from global state and add a 15s warmup delay
1 parent 90af8cf commit 4297e17

6 files changed

Lines changed: 105 additions & 10 deletions

File tree

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: 20 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."""
@@ -241,8 +243,8 @@ def _do_initialize(self, config: StatsbeatConfig) -> bool:
241243
config.distro_version,
242244
)
243245

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

248250
self._config = config
@@ -258,6 +260,22 @@ def _do_initialize(self, config: StatsbeatConfig) -> bool:
258260
self._cleanup()
259261
return False
260262

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

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def __init__(
133133
_StatsbeatMetrics._COMMON_ATTRIBUTES["version"] = _get_version()
134134

135135
self._ikey = instrumentation_key
136-
self._feature = _StatsbeatFeature.NONE
136+
self._feature = _StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] or _StatsbeatFeature.NONE
137137
if not disable_offline_storage:
138138
self._feature |= _StatsbeatFeature.DISK_RETRY
139139
if has_credential:
@@ -264,6 +264,10 @@ def _get_feature_metric(self, options: CallbackOptions) -> Iterable[Observation]
264264
return observations
265265
# Feature metric
266266
# Check if any features were enabled during runtime
267+
feature_bits = int(_StatsbeatMetrics._FEATURE_ATTRIBUTES.get("feature") or 0)
268+
if feature_bits:
269+
self._feature |= feature_bits
270+
_StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] = self._feature
267271
if get_statsbeat_custom_events_feature_set():
268272
self._feature |= _StatsbeatFeature.CUSTOM_EVENTS_EXTENSION
269273
_StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] = self._feature

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

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,9 +366,16 @@ def test_initialize_invalid_config(self, mock_is_enabled):
366366
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader")
367367
@patch("azure.monitor.opentelemetry.exporter.export.metrics._exporter.AzureMonitorMetricExporter")
368368
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager._StatsbeatMetrics")
369+
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.threading.Timer")
369370
@patch("azure.monitor.opentelemetry.exporter.statsbeat._state.is_statsbeat_enabled")
370371
def test_initialize_success(
371-
self, mock_is_enabled, mock_statsbeat_metrics, mock_exporter_class, mock_reader_class, mock_meter_provider_class
372+
self,
373+
mock_is_enabled,
374+
mock_timer_class,
375+
mock_statsbeat_metrics,
376+
mock_exporter_class,
377+
mock_reader_class,
378+
mock_meter_provider_class,
372379
):
373380
"""Test successful initialization."""
374381
mock_is_enabled.return_value = True
@@ -388,6 +395,7 @@ def test_initialize_success(
388395
# Mock the statsbeat metrics
389396
mock_metrics = Mock()
390397
mock_statsbeat_metrics.return_value = mock_metrics
398+
mock_timer_class.return_value = Mock()
391399

392400
config = self._create_valid_config()
393401

@@ -404,9 +412,47 @@ def test_initialize_success(
404412
mock_reader_class.assert_called_once()
405413
mock_meter_provider_class.assert_called_once()
406414
mock_statsbeat_metrics.assert_called_once()
407-
mock_meter_provider.force_flush.assert_called_once()
415+
mock_timer_class.assert_called_once()
416+
mock_timer_class.return_value.start.assert_called_once()
417+
mock_meter_provider.force_flush.assert_not_called()
408418
mock_metrics.init_non_initial_metrics.assert_called_once()
409419

420+
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.threading.Timer")
421+
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.MeterProvider")
422+
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader")
423+
@patch("azure.monitor.opentelemetry.exporter.export.metrics._exporter.AzureMonitorMetricExporter")
424+
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager._StatsbeatMetrics")
425+
@patch("azure.monitor.opentelemetry.exporter.statsbeat._state.is_statsbeat_enabled")
426+
def test_initialize_with_delay_schedules_non_blocking_flush(
427+
self,
428+
mock_is_enabled,
429+
mock_statsbeat_metrics,
430+
mock_exporter_class,
431+
mock_reader_class,
432+
mock_meter_provider_class,
433+
mock_timer_class,
434+
):
435+
"""Test delayed initial statsbeat flush is scheduled asynchronously."""
436+
mock_is_enabled.return_value = True
437+
438+
mock_exporter_class.return_value = Mock()
439+
mock_reader_class.return_value = Mock()
440+
mock_meter_provider = Mock()
441+
mock_meter_provider_class.return_value = mock_meter_provider
442+
mock_metrics = Mock()
443+
mock_statsbeat_metrics.return_value = mock_metrics
444+
445+
mock_timer = Mock()
446+
mock_timer_class.return_value = mock_timer
447+
448+
result = self.manager.initialize(self._create_valid_config())
449+
450+
self.assertTrue(result)
451+
mock_timer_class.assert_called_once()
452+
self.assertEqual(mock_timer_class.call_args[0][0], 15)
453+
mock_timer.start.assert_called_once()
454+
mock_meter_provider.force_flush.assert_not_called()
455+
410456
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.MeterProvider")
411457
@patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader")
412458
@patch("azure.monitor.opentelemetry.exporter.export.metrics._exporter.AzureMonitorMetricExporter")

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,7 @@ def test_get_feature_metric_instrumentation(self):
716716
False,
717717
)
718718
metric._feature = _StatsbeatFeature.NONE
719+
_StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] = _StatsbeatFeature.NONE
719720
attributes = dict(_StatsbeatMetrics._COMMON_ATTRIBUTES)
720721
attributes.update(_StatsbeatMetrics._INSTRUMENTATION_ATTRIBUTES)
721722
self.assertEqual(attributes["type"], _FEATURE_TYPES.INSTRUMENTATION)
@@ -747,6 +748,7 @@ def test_get_feature_metric_instrumentation_none(self):
747748
False,
748749
)
749750
metric._feature = _StatsbeatFeature.NONE
751+
_StatsbeatMetrics._FEATURE_ATTRIBUTES["feature"] = _StatsbeatFeature.NONE
750752
self.assertEqual(_StatsbeatMetrics._INSTRUMENTATION_ATTRIBUTES["feature"], 0)
751753
with mock.patch("azure.monitor.opentelemetry.exporter._utils.get_instrumentations") as instrumentations:
752754
instrumentations.return_value = 0

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
)
1111
from azure.monitor.opentelemetry.exporter.statsbeat import StatsbeatConfig, _statsbeat
1212
from azure.monitor.opentelemetry.exporter.statsbeat._manager import StatsbeatManager
13-
from azure.monitor.opentelemetry.exporter.statsbeat._statsbeat_metrics import _StatsbeatFeature
13+
from azure.monitor.opentelemetry.exporter.statsbeat._statsbeat_metrics import _StatsbeatFeature, _StatsbeatMetrics
1414
from azure.monitor.opentelemetry.exporter.statsbeat._state import (
1515
_STATSBEAT_STATE,
1616
_STATSBEAT_STATE_LOCK,
@@ -24,6 +24,8 @@
2424

2525
# cSpell:disable
2626

27+
_StatsbeatMetrics_FEATURE_ATTRIBUTES = dict(_StatsbeatMetrics._FEATURE_ATTRIBUTES)
28+
2729

2830
# pylint: disable=protected-access, unused-argument
2931
class TestStatsbeat(unittest.TestCase):
@@ -41,6 +43,7 @@ def setUp(self):
4143
_STATSBEAT_STATE["CUSTOM_EVENTS_FEATURE_SET"] = False
4244
_STATSBEAT_STATE["LIVE_METRICS_FEATURE_SET"] = False
4345
_STATSBEAT_STATE["CUSTOMER_SDKSTATS_FEATURE_SET"] = False
46+
_StatsbeatMetrics._FEATURE_ATTRIBUTES = dict(_StatsbeatMetrics_FEATURE_ATTRIBUTES)
4447

4548
def tearDown(self):
4649
"""Clean up after tests."""
@@ -56,7 +59,10 @@ def tearDown(self):
5659
@mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.MeterProvider")
5760
@mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader")
5861
@mock.patch("azure.monitor.opentelemetry.exporter.AzureMonitorMetricExporter")
59-
def test_collect_statsbeat_metrics(self, mock_exporter, mock_reader, mock_meter_provider, mock_statsbeat_metrics):
62+
@mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.threading.Timer")
63+
def test_collect_statsbeat_metrics(
64+
self, mock_timer_class, mock_exporter, mock_reader, mock_meter_provider, mock_statsbeat_metrics
65+
):
6066
"""Test that collect_statsbeat_metrics properly initializes statsbeat collection."""
6167
# Arrange
6268
exporter = mock.Mock()
@@ -78,6 +84,9 @@ def test_collect_statsbeat_metrics(self, mock_exporter, mock_reader, mock_meter_
7884
flush_mock = mock.Mock()
7985
mock_meter_provider_instance.force_flush = flush_mock
8086

87+
mock_timer = mock.Mock()
88+
mock_timer_class.return_value = mock_timer
89+
8190
mock_statsbeat_metrics_instance = mock.Mock()
8291
mock_statsbeat_metrics.return_value = mock_statsbeat_metrics_instance
8392

@@ -87,6 +96,10 @@ def test_collect_statsbeat_metrics(self, mock_exporter, mock_reader, mock_meter_
8796
# Act
8897
_statsbeat.collect_statsbeat_metrics(exporter)
8998

99+
# Simulate warmup timer callback execution.
100+
timer_callback = mock_timer_class.call_args[0][1]
101+
timer_callback()
102+
90103
# Assert - verify manager is initialized
91104
self.assertTrue(manager._initialized)
92105
self.assertEqual(manager._metrics, mock_statsbeat_metrics_instance)
@@ -329,8 +342,15 @@ def test_collect_statsbeat_metrics_no_callback_when_init_fails(
329342
@mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.MeterProvider")
330343
@mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.PeriodicExportingMetricReader")
331344
@mock.patch("azure.monitor.opentelemetry.exporter.export.metrics._exporter.AzureMonitorMetricExporter")
345+
@mock.patch("azure.monitor.opentelemetry.exporter.statsbeat._manager.threading.Timer")
332346
def test_collect_statsbeat_metrics_exists(
333-
self, mock_exporter, mock_reader, mock_meter_provider, mock_statsbeat_metrics, mock_get_manager
347+
self,
348+
mock_timer_class,
349+
mock_exporter,
350+
mock_reader,
351+
mock_meter_provider,
352+
mock_statsbeat_metrics,
353+
mock_get_manager,
334354
):
335355
"""Test that collect_statsbeat_metrics reuses existing configuration when called multiple times with same config.""" # pylint: disable=line-too-long
336356
# Arrange
@@ -353,6 +373,9 @@ def test_collect_statsbeat_metrics_exists(
353373
flush_mock = mock.Mock()
354374
mock_meter_provider_instance.force_flush = flush_mock
355375

376+
mock_timer = mock.Mock()
377+
mock_timer_class.return_value = mock_timer
378+
356379
mock_statsbeat_metrics_instance = mock.Mock()
357380
mock_statsbeat_metrics.return_value = mock_statsbeat_metrics_instance
358381

@@ -362,6 +385,8 @@ def test_collect_statsbeat_metrics_exists(
362385

363386
# Act - Initialize first time
364387
_statsbeat.collect_statsbeat_metrics(exporter)
388+
timer_callback = mock_timer_class.call_args[0][1]
389+
timer_callback()
365390
first_metrics = manager._metrics
366391
self.assertTrue(manager._initialized)
367392
self.assertEqual(first_metrics, mock_statsbeat_metrics_instance)

0 commit comments

Comments
 (0)