From 8d6e434c3d303fe4eb9799dc0f26d35b75ebbc3f Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Thu, 4 Jun 2026 16:02:29 -0700 Subject: [PATCH 01/13] Add StatsbeatManager.add_metric_callback to let SDKs/distros add their own metric observations to built-in statsbeat metrics --- .../CHANGELOG.md | 2 + .../exporter/statsbeat/_manager.py | 20 ++- .../exporter/statsbeat/_statsbeat_metrics.py | 9 ++ .../exporter/statsbeat/_utils.py | 36 ++++- .../tests/statsbeat/test_metrics.py | 135 ++++++++++++++++++ 5 files changed, 200 insertions(+), 2 deletions(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md b/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md index e54407c9309c..9df855af577a 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md @@ -3,6 +3,8 @@ ## 1.0.0b54 (Unreleased) ### Features Added +- Add `StatsbeatManager.add_metric_callback` to let SDKs/distros contribute extra + observations to built-in statsbeat metrics ### Breaking Changes - Customer Facing SDKStats: Renamed metric dimension attributes from snake_case/dotted to camelCase 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 index b435a95064db..38d84917a80a 100644 --- 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 @@ -2,8 +2,9 @@ # Licensed under the MIT License. import logging import threading -from typing import Optional, Any, Dict +from typing import Callable, Iterable, Optional, Any, Dict +from opentelemetry.metrics import CallbackOptions, Observation from opentelemetry.sdk.metrics import MeterProvider from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader from opentelemetry.sdk.resources import Resource @@ -19,6 +20,8 @@ _get_stats_long_export_interval, _get_stats_short_export_interval, _get_connection_string_for_region_from_config, + _ADDITIONAL_CALLBACKS, + _ADDITIONAL_CALLBACKS_LOCK, ) from azure.monitor.opentelemetry.exporter._utils import Singleton @@ -377,3 +380,18 @@ def is_initialized(self) -> bool: """ with self._lock: return self._initialized + + def add_metric_callback( + self, + metric_name: str, + callback: Callable[[CallbackOptions], Iterable[Observation]], + ) -> bool: + """Register an extra observation callback that an SDK/Distro with its own network sdkstats metric can use to + contribute rows to a built-in statsbeat metric. + """ + with _ADDITIONAL_CALLBACKS_LOCK: + callbacks = _ADDITIONAL_CALLBACKS.setdefault(metric_name, []) + if callback in callbacks: + return False + callbacks.append(callback) + return True 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 d0f20511aadf..d82aef1c62ff 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 @@ -40,6 +40,9 @@ get_statsbeat_customer_sdkstats_feature_set, get_statsbeat_browser_sdk_loader_feature_set, ) +from azure.monitor.opentelemetry.exporter.statsbeat._utils import ( + _iter_extra_observations as _additional_observations, +) from azure.monitor.opentelemetry.exporter import _utils @@ -379,6 +382,7 @@ def _get_success_count(self, options: CallbackOptions) -> Iterable[Observation]: if count != 0: observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_SUCCESS_NAME[1]] = 0 + observations.extend(_additional_observations(_REQ_SUCCESS_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -393,6 +397,7 @@ def _get_failure_count(self, options: CallbackOptions) -> Iterable[Observation]: attributes["statusCode"] = code observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_FAILURE_NAME[1]][code] = 0 # type: ignore + observations.extend(_additional_observations(_REQ_FAILURE_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -409,6 +414,7 @@ def _get_average_duration(self, options: CallbackOptions) -> Iterable[Observatio observations.append(Observation(result * 1000, dict(attributes))) _REQUESTS_MAP[_REQ_DURATION_NAME[1]] = 0 _REQUESTS_MAP["count"] = 0 + observations.extend(_additional_observations(_REQ_DURATION_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -423,6 +429,7 @@ def _get_retry_count(self, options: CallbackOptions) -> Iterable[Observation]: attributes["statusCode"] = code observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_RETRY_NAME[1]][code] = 0 # type: ignore + observations.extend(_additional_observations(_REQ_RETRY_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -437,6 +444,7 @@ def _get_throttle_count(self, options: CallbackOptions) -> Iterable[Observation] attributes["statusCode"] = code observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_THROTTLE_NAME[1]][code] = 0 # type: ignore + observations.extend(_additional_observations(_REQ_THROTTLE_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -451,6 +459,7 @@ def _get_exception_count(self, options: CallbackOptions) -> Iterable[Observation attributes["exceptionType"] = code observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_EXCEPTION_NAME[1]][code] = 0 # type: ignore + observations.extend(_additional_observations(_REQ_EXCEPTION_NAME[0], options)) return observations 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 07ed150cd33d..92c297ab3cba 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 -from collections.abc import Iterable # pylint: disable=import-error +import threading +from collections.abc import Iterable, Callable, Iterator # pylint: disable=import-error from typing import Optional, Dict +from opentelemetry.metrics import CallbackOptions, Observation from azure.monitor.opentelemetry.exporter._constants import ( _APPLICATIONINSIGHTS_STATS_CONNECTION_STRING_ENV_NAME, @@ -26,6 +28,9 @@ _REQUESTS_MAP_LOCK, ) +_ADDITIONAL_CALLBACKS: dict[str, list[Callable[[CallbackOptions], Iterable[Observation]]]] = {} +_ADDITIONAL_CALLBACKS_LOCK = threading.Lock() + def _get_stats_connection_string(endpoint: str) -> str: cs_env = os.environ.get(_APPLICATIONINSIGHTS_STATS_CONNECTION_STRING_ENV_NAME) @@ -165,3 +170,32 @@ def _get_connection_string_for_region_from_config(target_region: str, settings: "Unexpected error getting stats connection string for region '%s': %s", target_region, str(ex) ) return None + + +def _iter_extra_observations( + metric_name: str, options: CallbackOptions +) -> Iterator[Observation]: + """Yield observations contributed via :func:`add_metric_callback`. + + Invoked by the built-in ``_StatsbeatMetrics`` callbacks at collection time. + Snapshots the registered callbacks under the registry lock to avoid + mutation during iteration, then releases the lock before invoking user + callbacks (so they cannot deadlock against the registry). Exceptions raised + by individual callbacks are caught, logged, and skipped. + """ + + with _ADDITIONAL_CALLBACKS_LOCK: + callbacks = tuple(_ADDITIONAL_CALLBACKS.get(metric_name, ())) + + iter_logger = logging.getLogger(__name__) + for cb in callbacks: + try: + for observation in cb(options): + yield observation + except Exception: # pylint: disable=broad-except + iter_logger.debug( + "Extra statsbeat callback %r for %r raised; skipping.", + cb, + metric_name, + exc_info=True, + ) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py index 572074e32b31..501efff30b61 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py @@ -20,6 +20,9 @@ _REQ_SUCCESS_NAME, _REQ_THROTTLE_NAME, ) +from opentelemetry.metrics import Observation +from azure.monitor.opentelemetry.exporter.statsbeat import _utils as statsbeat_utils +from azure.monitor.opentelemetry.exporter.statsbeat._manager import StatsbeatManager from azure.monitor.opentelemetry.exporter.statsbeat._state import ( _REQUESTS_MAP, _STATSBEAT_STATE, @@ -35,6 +38,9 @@ _AttachTypes, _RP_Names, ) +from azure.monitor.opentelemetry.exporter.statsbeat._utils import ( + _iter_extra_observations, +) class MockResponse(object): @@ -967,4 +973,133 @@ def test_shorten_host(self): self.assertEqual(_shorten_host(url), "fakehost-5") +# pylint: disable=protected-access +class TestExtraObservationCallbacks(unittest.TestCase): + """Tests for StatsbeatManager.add_metric_callback and the _iter_extra_observations helper.""" + + def setUp(self): + statsbeat_utils._ADDITIONAL_CALLBACKS.clear() + _REQUESTS_MAP.clear() + + def tearDown(self): + statsbeat_utils._ADDITIONAL_CALLBACKS.clear() + _REQUESTS_MAP.clear() + + def _make_metric(self): + return _StatsbeatMetrics( + MeterProvider(), + "1aa11111-bbbb-1ccc-8ddd-eeeeffff3334", + "https://westus-1.in.applicationinsights.azure.com/", + False, + 0, + False, + ) + + # ---- StatsbeatManager.add_metric_callback ---- + + def test_add_returns_true_first_time(self): + cb = lambda options: [] # noqa: E731 + self.assertTrue(StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], cb)) + self.assertEqual(statsbeat_utils._ADDITIONAL_CALLBACKS[_REQ_SUCCESS_NAME[0]], [cb]) + + def test_add_is_idempotent_on_same_callback(self): + cb = lambda options: [] # noqa: E731 + self.assertTrue(StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], cb)) + self.assertFalse(StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], cb)) + self.assertEqual(statsbeat_utils._ADDITIONAL_CALLBACKS[_REQ_SUCCESS_NAME[0]], [cb]) + + def test_add_supports_multiple_distinct_callbacks(self): + cb1 = lambda options: [] # noqa: E731 + cb2 = lambda options: [] # noqa: E731 + self.assertTrue(StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], cb1)) + self.assertTrue(StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], cb2)) + self.assertEqual( + statsbeat_utils._ADDITIONAL_CALLBACKS[_REQ_SUCCESS_NAME[0]], + [cb1, cb2], + ) + + # ---- _iter_extra_observations ---- + + def test_iter_unregistered_name_yields_nothing(self): + self.assertEqual(list(_iter_extra_observations(_REQ_SUCCESS_NAME[0], None)), []) + + def test_iter_yields_observations_from_registered_callback(self): + obs = Observation(7, {"endpoint": "ep1"}) + + def cb(_options): + yield obs + + StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], cb) + self.assertEqual(list(_iter_extra_observations(_REQ_SUCCESS_NAME[0], None)), [obs]) + + def test_iter_aggregates_across_multiple_callbacks(self): + obs1 = Observation(1, {"endpoint": "ep1"}) + obs2 = Observation(2, {"endpoint": "ep2"}) + StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], lambda _options: [obs1]) + StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], lambda _options: [obs2]) + self.assertEqual( + list(_iter_extra_observations(_REQ_SUCCESS_NAME[0], None)), + [obs1, obs2], + ) + + def test_iter_swallows_callback_exception_and_continues(self): + good_obs = Observation(42, {"endpoint": "ok"}) + + def bad_cb(_options): + raise RuntimeError("boom") + + StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], bad_cb) + StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], lambda _options: [good_obs]) + # Should not raise; should still emit the good observation. + self.assertEqual( + list(_iter_extra_observations(_REQ_SUCCESS_NAME[0], None)), + [good_obs], + ) + + def test_iter_callbacks_for_other_metrics_not_invoked(self): + called = [] + StatsbeatManager().add_metric_callback( + _REQ_FAILURE_NAME[0], lambda _options: called.append("failure") or [] + ) + list(_iter_extra_observations(_REQ_SUCCESS_NAME[0], None)) + self.assertEqual(called, []) + + # ---- integration with built-in callbacks ---- + + def test_success_count_callback_emits_extras(self): + metric = self._make_metric() + _REQUESTS_MAP[_REQ_SUCCESS_NAME[1]] = 5 + + extra = Observation(99, {"endpoint": "extra-ep", "statusCode": 200}) + StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], lambda _options: [extra]) + + observations = metric._get_success_count(options=None) + + # Built-in observation followed by the extra one. + self.assertEqual(len(observations), 2) + self.assertEqual(observations[0].value, 5) + self.assertIs(observations[-1], extra) + + def test_success_count_callback_unchanged_without_extras(self): + metric = self._make_metric() + _REQUESTS_MAP[_REQ_SUCCESS_NAME[1]] = 3 + + observations = metric._get_success_count(options=None) + + self.assertEqual(len(observations), 1) + self.assertEqual(observations[0].value, 3) + + def test_extras_for_other_metric_do_not_leak_into_success(self): + metric = self._make_metric() + _REQUESTS_MAP[_REQ_SUCCESS_NAME[1]] = 1 + + unrelated = Observation(123, {"endpoint": "other"}) + StatsbeatManager().add_metric_callback(_REQ_FAILURE_NAME[0], lambda _options: [unrelated]) + + observations = metric._get_success_count(options=None) + + self.assertEqual(len(observations), 1) + self.assertEqual(observations[0].value, 1) + self.assertNotIn(unrelated, observations) + # cSpell:enable From 7ec31b4daf862cd75dbc4d85c499393b038e288f Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Thu, 4 Jun 2026 16:02:44 -0700 Subject: [PATCH 02/13] Add CHANGELOG --- sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md b/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md index 9df855af577a..7ea78ee8f888 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md @@ -3,7 +3,7 @@ ## 1.0.0b54 (Unreleased) ### Features Added -- Add `StatsbeatManager.add_metric_callback` to let SDKs/distros contribute extra +- Add `StatsbeatManager.add_metric_callback` to let SDKs/distros add their own metric observations to built-in statsbeat metrics ### Breaking Changes From 3833cfb7b9923b7b82af7d68632f3a42db1b5683 Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Thu, 4 Jun 2026 16:04:47 -0700 Subject: [PATCH 03/13] Update CHANGELOG --- sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md b/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md index 7ea78ee8f888..e0a79e9d5e80 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md @@ -5,7 +5,7 @@ ### Features Added - Add `StatsbeatManager.add_metric_callback` to let SDKs/distros add their own metric observations to built-in statsbeat metrics - + ([#47363](https://github.com/Azure/azure-sdk-for-python/pull/47363)) ### Breaking Changes - Customer Facing SDKStats: Renamed metric dimension attributes from snake_case/dotted to camelCase (`compute_type` -> `computeType`, `telemetry_type` -> `telemetryType`, `telemetry_success` -> `telemetrySuccess`, From ab467b52bb6d7a7cb3cbb388d5452ef0c04f97fa Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Thu, 4 Jun 2026 16:57:06 -0700 Subject: [PATCH 04/13] Address feedback --- .../monitor/opentelemetry/exporter/statsbeat/_utils.py | 4 ++-- .../tests/statsbeat/test_metrics.py | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) 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 92c297ab3cba..8c51b5623f68 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 @@ -5,7 +5,7 @@ import json import threading from collections.abc import Iterable, Callable, Iterator # pylint: disable=import-error -from typing import Optional, Dict +from typing import Optional, Dict, List from opentelemetry.metrics import CallbackOptions, Observation from azure.monitor.opentelemetry.exporter._constants import ( @@ -28,7 +28,7 @@ _REQUESTS_MAP_LOCK, ) -_ADDITIONAL_CALLBACKS: dict[str, list[Callable[[CallbackOptions], Iterable[Observation]]]] = {} +_ADDITIONAL_CALLBACKS: Dict[str, List[Callable[[CallbackOptions], Iterable[Observation]]]] = {} _ADDITIONAL_CALLBACKS_LOCK = threading.Lock() diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py index 501efff30b61..fc9217c767fa 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py @@ -978,11 +978,13 @@ class TestExtraObservationCallbacks(unittest.TestCase): """Tests for StatsbeatManager.add_metric_callback and the _iter_extra_observations helper.""" def setUp(self): - statsbeat_utils._ADDITIONAL_CALLBACKS.clear() + with statsbeat_utils._ADDITIONAL_CALLBACKS_LOCK: + statsbeat_utils._ADDITIONAL_CALLBACKS.clear() _REQUESTS_MAP.clear() def tearDown(self): - statsbeat_utils._ADDITIONAL_CALLBACKS.clear() + with statsbeat_utils._ADDITIONAL_CALLBACKS_LOCK: + statsbeat_utils._ADDITIONAL_CALLBACKS.clear() _REQUESTS_MAP.clear() def _make_metric(self): From c4faa82e7153f1aadf8085c335902036ceade00f Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Thu, 4 Jun 2026 16:59:25 -0700 Subject: [PATCH 05/13] Avoid race conditions in CI pipelines --- .../tests/statsbeat/test_metrics.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py index fc9217c767fa..501efff30b61 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py @@ -978,13 +978,11 @@ class TestExtraObservationCallbacks(unittest.TestCase): """Tests for StatsbeatManager.add_metric_callback and the _iter_extra_observations helper.""" def setUp(self): - with statsbeat_utils._ADDITIONAL_CALLBACKS_LOCK: - statsbeat_utils._ADDITIONAL_CALLBACKS.clear() + statsbeat_utils._ADDITIONAL_CALLBACKS.clear() _REQUESTS_MAP.clear() def tearDown(self): - with statsbeat_utils._ADDITIONAL_CALLBACKS_LOCK: - statsbeat_utils._ADDITIONAL_CALLBACKS.clear() + statsbeat_utils._ADDITIONAL_CALLBACKS.clear() _REQUESTS_MAP.clear() def _make_metric(self): From 8ef03e937f5ddf54d190d9c52a8f7794f41cc3b2 Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Fri, 5 Jun 2026 09:58:49 -0700 Subject: [PATCH 06/13] Fix lint and format --- .../opentelemetry/exporter/statsbeat/_manager.py | 7 +++++++ .../opentelemetry/exporter/statsbeat/_utils.py | 14 +++++++++----- .../tests/statsbeat/test_metrics.py | 5 ++--- 3 files changed, 18 insertions(+), 8 deletions(-) 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 index 38d84917a80a..b5b6351eddfa 100644 --- 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 @@ -388,6 +388,13 @@ def add_metric_callback( ) -> bool: """Register an extra observation callback that an SDK/Distro with its own network sdkstats metric can use to contribute rows to a built-in statsbeat metric. + + :param metric_name: Name of the built-in statsbeat metric to extend. + :type metric_name: str + :param callback: OpenTelemetry observable-gauge callback ``(CallbackOptions) -> Iterable[Observation]``. + :type callback: Callable[[CallbackOptions], Iterable[Observation]] + :returns: ``True`` if newly registered, ``False`` if already registered. + :rtype: bool """ with _ADDITIONAL_CALLBACKS_LOCK: callbacks = _ADDITIONAL_CALLBACKS.setdefault(metric_name, []) 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 8c51b5623f68..9ab8565de8cd 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 @@ -172,9 +172,7 @@ def _get_connection_string_for_region_from_config(target_region: str, settings: return None -def _iter_extra_observations( - metric_name: str, options: CallbackOptions -) -> Iterator[Observation]: +def _iter_extra_observations(metric_name: str, options: CallbackOptions) -> Iterator[Observation]: """Yield observations contributed via :func:`add_metric_callback`. Invoked by the built-in ``_StatsbeatMetrics`` callbacks at collection time. @@ -182,6 +180,13 @@ def _iter_extra_observations( mutation during iteration, then releases the lock before invoking user callbacks (so they cannot deadlock against the registry). Exceptions raised by individual callbacks are caught, logged, and skipped. + + :param metric_name: Name of the built-in statsbeat metric being collected. + :type metric_name: str + :param options: OpenTelemetry callback options forwarded to each registered callback. + :type options: ~opentelemetry.metrics.CallbackOptions + :returns: Iterator over observations contributed by registered callbacks. + :rtype: Iterator[~opentelemetry.metrics.Observation] """ with _ADDITIONAL_CALLBACKS_LOCK: @@ -190,8 +195,7 @@ def _iter_extra_observations( iter_logger = logging.getLogger(__name__) for cb in callbacks: try: - for observation in cb(options): - yield observation + yield from cb(options) except Exception: # pylint: disable=broad-except iter_logger.debug( "Extra statsbeat callback %r for %r raised; skipping.", diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py index 501efff30b61..2c245b34a9f4 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py @@ -1058,9 +1058,7 @@ def bad_cb(_options): def test_iter_callbacks_for_other_metrics_not_invoked(self): called = [] - StatsbeatManager().add_metric_callback( - _REQ_FAILURE_NAME[0], lambda _options: called.append("failure") or [] - ) + StatsbeatManager().add_metric_callback(_REQ_FAILURE_NAME[0], lambda _options: called.append("failure") or []) list(_iter_extra_observations(_REQ_SUCCESS_NAME[0], None)) self.assertEqual(called, []) @@ -1102,4 +1100,5 @@ def test_extras_for_other_metric_do_not_leak_into_success(self): self.assertEqual(observations[0].value, 1) self.assertNotIn(unrelated, observations) + # cSpell:enable From a8a1f79945286f1b33ac187ce6329ab7d424c315 Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Fri, 12 Jun 2026 14:09:17 -0700 Subject: [PATCH 07/13] Rename class --- .../exporter/statsbeat/_statsbeat_metrics.py | 14 +++++++------- .../opentelemetry/exporter/statsbeat/_utils.py | 2 +- .../tests/statsbeat/test_metrics.py | 18 +++++++++--------- 3 files changed, 17 insertions(+), 17 deletions(-) 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 d82aef1c62ff..66afe56aa15c 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 @@ -41,7 +41,7 @@ get_statsbeat_browser_sdk_loader_feature_set, ) from azure.monitor.opentelemetry.exporter.statsbeat._utils import ( - _iter_extra_observations as _additional_observations, + _iter_additional_observations, ) from azure.monitor.opentelemetry.exporter import _utils @@ -382,7 +382,7 @@ def _get_success_count(self, options: CallbackOptions) -> Iterable[Observation]: if count != 0: observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_SUCCESS_NAME[1]] = 0 - observations.extend(_additional_observations(_REQ_SUCCESS_NAME[0], options)) + observations.extend(_iter_additional_observations(_REQ_SUCCESS_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -397,7 +397,7 @@ def _get_failure_count(self, options: CallbackOptions) -> Iterable[Observation]: attributes["statusCode"] = code observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_FAILURE_NAME[1]][code] = 0 # type: ignore - observations.extend(_additional_observations(_REQ_FAILURE_NAME[0], options)) + observations.extend(_iter_additional_observations(_REQ_FAILURE_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -414,7 +414,7 @@ def _get_average_duration(self, options: CallbackOptions) -> Iterable[Observatio observations.append(Observation(result * 1000, dict(attributes))) _REQUESTS_MAP[_REQ_DURATION_NAME[1]] = 0 _REQUESTS_MAP["count"] = 0 - observations.extend(_additional_observations(_REQ_DURATION_NAME[0], options)) + observations.extend(_iter_additional_observations(_REQ_DURATION_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -429,7 +429,7 @@ def _get_retry_count(self, options: CallbackOptions) -> Iterable[Observation]: attributes["statusCode"] = code observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_RETRY_NAME[1]][code] = 0 # type: ignore - observations.extend(_additional_observations(_REQ_RETRY_NAME[0], options)) + observations.extend(_iter_additional_observations(_REQ_RETRY_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -444,7 +444,7 @@ def _get_throttle_count(self, options: CallbackOptions) -> Iterable[Observation] attributes["statusCode"] = code observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_THROTTLE_NAME[1]][code] = 0 # type: ignore - observations.extend(_additional_observations(_REQ_THROTTLE_NAME[0], options)) + observations.extend(_iter_additional_observations(_REQ_THROTTLE_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -459,7 +459,7 @@ def _get_exception_count(self, options: CallbackOptions) -> Iterable[Observation attributes["exceptionType"] = code observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_EXCEPTION_NAME[1]][code] = 0 # type: ignore - observations.extend(_additional_observations(_REQ_EXCEPTION_NAME[0], options)) + observations.extend(_iter_additional_observations(_REQ_EXCEPTION_NAME[0], options)) return observations 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 9ab8565de8cd..3c45d7f20f61 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 @@ -172,7 +172,7 @@ def _get_connection_string_for_region_from_config(target_region: str, settings: return None -def _iter_extra_observations(metric_name: str, options: CallbackOptions) -> Iterator[Observation]: +def _iter_additional_observations(metric_name: str, options: CallbackOptions) -> Iterator[Observation]: """Yield observations contributed via :func:`add_metric_callback`. Invoked by the built-in ``_StatsbeatMetrics`` callbacks at collection time. diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py index 2c245b34a9f4..daf9491f5547 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py @@ -39,7 +39,7 @@ _RP_Names, ) from azure.monitor.opentelemetry.exporter.statsbeat._utils import ( - _iter_extra_observations, + _iter_additional_observations, ) @@ -974,8 +974,8 @@ def test_shorten_host(self): # pylint: disable=protected-access -class TestExtraObservationCallbacks(unittest.TestCase): - """Tests for StatsbeatManager.add_metric_callback and the _iter_extra_observations helper.""" +class TestAdditionalObservationCallbacks(unittest.TestCase): + """Tests for StatsbeatManager.add_metric_callback and the _iter_additional_observations helper.""" def setUp(self): statsbeat_utils._ADDITIONAL_CALLBACKS.clear() @@ -1018,10 +1018,10 @@ def test_add_supports_multiple_distinct_callbacks(self): [cb1, cb2], ) - # ---- _iter_extra_observations ---- + # ---- _iter_additional_observations ---- def test_iter_unregistered_name_yields_nothing(self): - self.assertEqual(list(_iter_extra_observations(_REQ_SUCCESS_NAME[0], None)), []) + self.assertEqual(list(_iter_additional_observations(_REQ_SUCCESS_NAME[0], None)), []) def test_iter_yields_observations_from_registered_callback(self): obs = Observation(7, {"endpoint": "ep1"}) @@ -1030,7 +1030,7 @@ def cb(_options): yield obs StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], cb) - self.assertEqual(list(_iter_extra_observations(_REQ_SUCCESS_NAME[0], None)), [obs]) + self.assertEqual(list(_iter_additional_observations(_REQ_SUCCESS_NAME[0], None)), [obs]) def test_iter_aggregates_across_multiple_callbacks(self): obs1 = Observation(1, {"endpoint": "ep1"}) @@ -1038,7 +1038,7 @@ def test_iter_aggregates_across_multiple_callbacks(self): StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], lambda _options: [obs1]) StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], lambda _options: [obs2]) self.assertEqual( - list(_iter_extra_observations(_REQ_SUCCESS_NAME[0], None)), + list(_iter_additional_observations(_REQ_SUCCESS_NAME[0], None)), [obs1, obs2], ) @@ -1052,14 +1052,14 @@ def bad_cb(_options): StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], lambda _options: [good_obs]) # Should not raise; should still emit the good observation. self.assertEqual( - list(_iter_extra_observations(_REQ_SUCCESS_NAME[0], None)), + list(_iter_additional_observations(_REQ_SUCCESS_NAME[0], None)), [good_obs], ) def test_iter_callbacks_for_other_metrics_not_invoked(self): called = [] StatsbeatManager().add_metric_callback(_REQ_FAILURE_NAME[0], lambda _options: called.append("failure") or []) - list(_iter_extra_observations(_REQ_SUCCESS_NAME[0], None)) + list(_iter_additional_observations(_REQ_SUCCESS_NAME[0], None)) self.assertEqual(called, []) # ---- integration with built-in callbacks ---- From 3df873bce6261edb9266d996c5efe18528acf835 Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Fri, 12 Jun 2026 14:56:09 -0700 Subject: [PATCH 08/13] Retrigger CI/CD pipeline From d98ef621d73ba0722e50c5e1b8366d8c79ea0685 Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Tue, 16 Jun 2026 14:19:04 -0700 Subject: [PATCH 09/13] Address feedback --- .../CHANGELOG.md | 1 + .../api.md | 183 ------------------ .../api.metadata.yml | 3 - .../exporter/statsbeat/_manager.py | 31 +-- .../exporter/statsbeat/_statsbeat_metrics.py | 14 +- .../exporter/statsbeat/_utils.py | 30 ++- .../tests/statsbeat/test_metrics.py | 75 +++---- 7 files changed, 58 insertions(+), 279 deletions(-) delete mode 100644 sdk/monitor/azure-monitor-opentelemetry-exporter/api.md delete mode 100644 sdk/monitor/azure-monitor-opentelemetry-exporter/api.metadata.yml diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md b/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md index e0a79e9d5e80..dc757965f680 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md @@ -6,6 +6,7 @@ - Add `StatsbeatManager.add_metric_callback` to let SDKs/distros add their own metric observations to built-in statsbeat metrics ([#47363](https://github.com/Azure/azure-sdk-for-python/pull/47363)) + ### Breaking Changes - Customer Facing SDKStats: Renamed metric dimension attributes from snake_case/dotted to camelCase (`compute_type` -> `computeType`, `telemetry_type` -> `telemetryType`, `telemetry_success` -> `telemetrySuccess`, diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/api.md b/sdk/monitor/azure-monitor-opentelemetry-exporter/api.md deleted file mode 100644 index ffa8878c7e30..000000000000 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/api.md +++ /dev/null @@ -1,183 +0,0 @@ -```py -namespace azure.monitor.opentelemetry.exporter - - class azure.monitor.opentelemetry.exporter.ApplicationInsightsSampler(Sampler): - - def __init__(self, sampling_ratio: float = 1.0): ... - - def get_description(self) -> str: ... - - def should_sample( - self, - parent_context: Optional[Context], - trace_id: int, - name: str, - kind: Optional[SpanKind] = None, - attributes: Attributes = None, - links: Optional[Sequence[Link]] = None, - trace_state: Optional[TraceState] = None - ) -> SamplingResult: ... - - - class azure.monitor.opentelemetry.exporter.AzureMonitorLogExporter(BaseExporter, LogRecordExporter): - - def __init__( - self, - *, - api_version: Optional[str] = ..., - connection_string: Optional[str] = ..., - credential: Optional[ManagedIdentityCredential/ClientSecretCredential] = ..., - disable_offline_storage: Optional[bool] = ..., - max_envelopes_per_second: Optional[int] = ..., - storage_directory: Optional[str] = ..., - **kwargs: Any - ) -> None: ... - - @classmethod - def from_connection_string( - cls, - conn_str: str, - *, - api_version: Optional[str] = ..., - **kwargs: Any - ) -> AzureMonitorLogExporter: ... - - def export( - self, - batch: Sequence[ReadableLogRecord], - **kwargs: Any - ) -> LogRecordExportResult: ... - - def shutdown(self) -> None: ... - - - class azure.monitor.opentelemetry.exporter.AzureMonitorMetricExporter(BaseExporter, MetricExporter): - - def __init__(self, **kwargs: Any) -> None: ... - - @classmethod - def from_connection_string( - cls, - conn_str: str, - *, - api_version: Optional[str] = ..., - **kwargs: Any - ) -> AzureMonitorMetricExporter: ... - - def export( - self, - metrics_data: OTMetricsData, - timeout_millis: float = 10000, - **kwargs: Any - ) -> MetricExportResult: ... - - def force_flush(self, timeout_millis: float = 10000) -> bool: ... - - def shutdown( - self, - timeout_millis: float = 30000, - **kwargs: Any - ) -> None: ... - - - class azure.monitor.opentelemetry.exporter.AzureMonitorTraceExporter(BaseExporter, SpanExporter): - - def __init__(self, **kwargs: Any): ... - - @classmethod - def from_connection_string( - cls, - conn_str: str, - *, - api_version: Optional[str] = ..., - **kwargs: Any - ) -> AzureMonitorTraceExporter: ... - - def export( - self, - spans: Sequence[ReadableSpan], - **_kwargs: Any - ) -> SpanExportResult: ... - - def shutdown(self) -> None: ... - - - class azure.monitor.opentelemetry.exporter.RateLimitedSampler(Sampler): - - def __init__(self, target_spans_per_second_limit: float): ... - - def get_description(self) -> str: ... - - def should_sample( - self, - parent_context: Optional[Context], - trace_id: int, - name: str, - kind: Optional[SpanKind] = None, - attributes: Attributes = None, - links: Optional[Sequence[Link]] = None, - trace_state: Optional[TraceState] = None - ) -> SamplingResult: ... - - -namespace azure.monitor.opentelemetry.exporter.statsbeat - - def azure.monitor.opentelemetry.exporter.statsbeat.collect_statsbeat_metrics(exporter: BaseExporter) -> None: ... - - - def azure.monitor.opentelemetry.exporter.statsbeat.shutdown_statsbeat_metrics() -> bool: ... - - - class azure.monitor.opentelemetry.exporter.statsbeat.StatsbeatConfig: - - def __eq__(self, other: object) -> bool: ... - - def __hash__(self) -> int: ... - - def __init__( - self, - endpoint: str, - region: str, - instrumentation_key: str, - disable_offline_storage: bool = False, - credential: Optional[Any] = None, - distro_version: Optional[str] = None, - connection_string: Optional[str] = None - ) -> None: ... - - @classmethod - def from_config( - cls, - base_config: StatsbeatConfig, - config_dict: Dict[str, str] - ) -> Optional[StatsbeatConfig]: ... - - @classmethod - def from_exporter(cls, exporter: Any) -> Optional[StatsbeatConfig]: ... - - - class azure.monitor.opentelemetry.exporter.statsbeat.StatsbeatManager(metaclass=Singleton): - - def __init__(self) -> None: ... - - def get_current_config(self) -> Optional[StatsbeatConfig]: ... - - def initialize(self, config: StatsbeatConfig) -> bool: ... - - def is_initialized(self) -> bool: ... - - def shutdown(self) -> bool: ... - - -namespace azure.monitor.opentelemetry.exporter.statsbeat.customer - - def azure.monitor.opentelemetry.exporter.statsbeat.customer.collect_customer_sdkstats(exporter: BaseExporter) -> None: ... - - - def azure.monitor.opentelemetry.exporter.statsbeat.customer.get_customer_stats_manager() -> CustomerSdkStatsManager: ... - - - def azure.monitor.opentelemetry.exporter.statsbeat.customer.shutdown_customer_sdkstats_metrics() -> None: ... - - -``` \ No newline at end of file diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/api.metadata.yml b/sdk/monitor/azure-monitor-opentelemetry-exporter/api.metadata.yml deleted file mode 100644 index b4d22b0534a0..000000000000 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/api.metadata.yml +++ /dev/null @@ -1,3 +0,0 @@ -apiMdSha256: 4e626c08830ccebb3dab6dab00472543831b70bae5cd17f3bf1432d938991f5b -parserVersion: 0.3.28 -pythonVersion: 3.13.14 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 index b5b6351eddfa..8183868992f1 100644 --- 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 @@ -2,7 +2,7 @@ # Licensed under the MIT License. import logging import threading -from typing import Callable, Iterable, Optional, Any, Dict +from typing import Callable, Iterable, List, Optional, Any, Dict from opentelemetry.metrics import CallbackOptions, Observation from opentelemetry.sdk.metrics import MeterProvider @@ -20,8 +20,6 @@ _get_stats_long_export_interval, _get_stats_short_export_interval, _get_connection_string_for_region_from_config, - _ADDITIONAL_CALLBACKS, - _ADDITIONAL_CALLBACKS_LOCK, ) from azure.monitor.opentelemetry.exporter._utils import Singleton @@ -164,6 +162,11 @@ def __init__(self) -> None: # Set during first initialization, preserved in shutdown for potential re-initialization self._config: Optional[StatsbeatConfig] = None # type: ignore + # Extra observation callbacks contributed by SDKs/distros. Keyed by built-in + # statsbeat metric name. Registered directly on the singleton instance, e.g. + # ``StatsbeatManager()._additional_callbacks.setdefault(name, []).append(cb)``. + self._additional_callbacks: Dict[str, List[Callable[[CallbackOptions], Iterable[Observation]]]] = {} + @staticmethod def _validate_config(config: Optional[StatsbeatConfig]) -> bool: """Validate that a configuration has all required fields. @@ -381,24 +384,4 @@ def is_initialized(self) -> bool: with self._lock: return self._initialized - def add_metric_callback( - self, - metric_name: str, - callback: Callable[[CallbackOptions], Iterable[Observation]], - ) -> bool: - """Register an extra observation callback that an SDK/Distro with its own network sdkstats metric can use to - contribute rows to a built-in statsbeat metric. - - :param metric_name: Name of the built-in statsbeat metric to extend. - :type metric_name: str - :param callback: OpenTelemetry observable-gauge callback ``(CallbackOptions) -> Iterable[Observation]``. - :type callback: Callable[[CallbackOptions], Iterable[Observation]] - :returns: ``True`` if newly registered, ``False`` if already registered. - :rtype: bool - """ - with _ADDITIONAL_CALLBACKS_LOCK: - callbacks = _ADDITIONAL_CALLBACKS.setdefault(metric_name, []) - if callback in callbacks: - return False - callbacks.append(callback) - return True + 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 66afe56aa15c..651ed2f83f1a 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 @@ -41,7 +41,7 @@ get_statsbeat_browser_sdk_loader_feature_set, ) from azure.monitor.opentelemetry.exporter.statsbeat._utils import ( - _iter_additional_observations, + _get_additional_observations, ) from azure.monitor.opentelemetry.exporter import _utils @@ -382,7 +382,7 @@ def _get_success_count(self, options: CallbackOptions) -> Iterable[Observation]: if count != 0: observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_SUCCESS_NAME[1]] = 0 - observations.extend(_iter_additional_observations(_REQ_SUCCESS_NAME[0], options)) + observations.extend(_get_additional_observations(_REQ_SUCCESS_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -397,7 +397,7 @@ def _get_failure_count(self, options: CallbackOptions) -> Iterable[Observation]: attributes["statusCode"] = code observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_FAILURE_NAME[1]][code] = 0 # type: ignore - observations.extend(_iter_additional_observations(_REQ_FAILURE_NAME[0], options)) + observations.extend(_get_additional_observations(_REQ_FAILURE_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -414,7 +414,7 @@ def _get_average_duration(self, options: CallbackOptions) -> Iterable[Observatio observations.append(Observation(result * 1000, dict(attributes))) _REQUESTS_MAP[_REQ_DURATION_NAME[1]] = 0 _REQUESTS_MAP["count"] = 0 - observations.extend(_iter_additional_observations(_REQ_DURATION_NAME[0], options)) + observations.extend(_get_additional_observations(_REQ_DURATION_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -429,7 +429,7 @@ def _get_retry_count(self, options: CallbackOptions) -> Iterable[Observation]: attributes["statusCode"] = code observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_RETRY_NAME[1]][code] = 0 # type: ignore - observations.extend(_iter_additional_observations(_REQ_RETRY_NAME[0], options)) + observations.extend(_get_additional_observations(_REQ_RETRY_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -444,7 +444,7 @@ def _get_throttle_count(self, options: CallbackOptions) -> Iterable[Observation] attributes["statusCode"] = code observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_THROTTLE_NAME[1]][code] = 0 # type: ignore - observations.extend(_iter_additional_observations(_REQ_THROTTLE_NAME[0], options)) + observations.extend(_get_additional_observations(_REQ_THROTTLE_NAME[0], options)) return observations # pylint: disable=unused-argument @@ -459,7 +459,7 @@ def _get_exception_count(self, options: CallbackOptions) -> Iterable[Observation attributes["exceptionType"] = code observations.append(Observation(int(count), dict(attributes))) _REQUESTS_MAP[_REQ_EXCEPTION_NAME[1]][code] = 0 # type: ignore - observations.extend(_iter_additional_observations(_REQ_EXCEPTION_NAME[0], options)) + observations.extend(_get_additional_observations(_REQ_EXCEPTION_NAME[0], options)) return observations 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 3c45d7f20f61..bc60dcc99090 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,7 @@ import os import logging import json -import threading -from collections.abc import Iterable, Callable, Iterator # pylint: disable=import-error +from collections.abc import Iterable # pylint: disable=import-error from typing import Optional, Dict, List from opentelemetry.metrics import CallbackOptions, Observation @@ -28,9 +27,6 @@ _REQUESTS_MAP_LOCK, ) -_ADDITIONAL_CALLBACKS: Dict[str, List[Callable[[CallbackOptions], Iterable[Observation]]]] = {} -_ADDITIONAL_CALLBACKS_LOCK = threading.Lock() - def _get_stats_connection_string(endpoint: str) -> str: cs_env = os.environ.get(_APPLICATIONINSIGHTS_STATS_CONNECTION_STRING_ENV_NAME) @@ -172,30 +168,31 @@ def _get_connection_string_for_region_from_config(target_region: str, settings: return None -def _iter_additional_observations(metric_name: str, options: CallbackOptions) -> Iterator[Observation]: - """Yield observations contributed via :func:`add_metric_callback`. +def _get_additional_observations(metric_name: str, options: CallbackOptions) -> List[Observation]: + """Return observations contributed by extra callbacks registered on :class:`StatsbeatManager`. Invoked by the built-in ``_StatsbeatMetrics`` callbacks at collection time. - Snapshots the registered callbacks under the registry lock to avoid - mutation during iteration, then releases the lock before invoking user - callbacks (so they cannot deadlock against the registry). Exceptions raised - by individual callbacks are caught, logged, and skipped. + Reads ``StatsbeatManager()._additional_callbacks`` (a live mutable dict on the + singleton instance), which SDKs/distros populate directly. Exceptions raised by + individual callbacks are caught, logged, and skipped. :param metric_name: Name of the built-in statsbeat metric being collected. :type metric_name: str :param options: OpenTelemetry callback options forwarded to each registered callback. :type options: ~opentelemetry.metrics.CallbackOptions - :returns: Iterator over observations contributed by registered callbacks. - :rtype: Iterator[~opentelemetry.metrics.Observation] + :returns: List of observations contributed by registered callbacks. + :rtype: list[~opentelemetry.metrics.Observation] """ + # Lazy import to avoid a circular import between _manager and _utils. + from azure.monitor.opentelemetry.exporter.statsbeat._manager import StatsbeatManager # pylint: disable=import-outside-toplevel - with _ADDITIONAL_CALLBACKS_LOCK: - callbacks = tuple(_ADDITIONAL_CALLBACKS.get(metric_name, ())) + callbacks = StatsbeatManager()._additional_callbacks.get(metric_name, ()) # pylint: disable=protected-access + observations: List[Observation] = [] iter_logger = logging.getLogger(__name__) for cb in callbacks: try: - yield from cb(options) + observations.extend(cb(options)) except Exception: # pylint: disable=broad-except iter_logger.debug( "Extra statsbeat callback %r for %r raised; skipping.", @@ -203,3 +200,4 @@ def _iter_additional_observations(metric_name: str, options: CallbackOptions) -> metric_name, exc_info=True, ) + return observations diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py index daf9491f5547..0e851664ba92 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py @@ -39,7 +39,7 @@ _RP_Names, ) from azure.monitor.opentelemetry.exporter.statsbeat._utils import ( - _iter_additional_observations, + _get_additional_observations, ) @@ -975,15 +975,21 @@ def test_shorten_host(self): # pylint: disable=protected-access class TestAdditionalObservationCallbacks(unittest.TestCase): - """Tests for StatsbeatManager.add_metric_callback and the _iter_additional_observations helper.""" + """Tests for StatsbeatManager._additional_callbacks and the _get_additional_observations helper.""" def setUp(self): - statsbeat_utils._ADDITIONAL_CALLBACKS.clear() _REQUESTS_MAP.clear() + # Force a fresh StatsbeatManager so its __init__ runs again (which + # rebuilds an empty _additional_callbacks dict on the instance). + StatsbeatManager._instances.pop(StatsbeatManager, None) def tearDown(self): - statsbeat_utils._ADDITIONAL_CALLBACKS.clear() _REQUESTS_MAP.clear() + StatsbeatManager._instances.pop(StatsbeatManager, None) + + @staticmethod + def _register(metric_name, callback): + StatsbeatManager()._additional_callbacks.setdefault(metric_name, []).append(callback) def _make_metric(self): return _StatsbeatMetrics( @@ -995,71 +1001,48 @@ def _make_metric(self): False, ) - # ---- StatsbeatManager.add_metric_callback ---- - - def test_add_returns_true_first_time(self): - cb = lambda options: [] # noqa: E731 - self.assertTrue(StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], cb)) - self.assertEqual(statsbeat_utils._ADDITIONAL_CALLBACKS[_REQ_SUCCESS_NAME[0]], [cb]) - - def test_add_is_idempotent_on_same_callback(self): - cb = lambda options: [] # noqa: E731 - self.assertTrue(StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], cb)) - self.assertFalse(StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], cb)) - self.assertEqual(statsbeat_utils._ADDITIONAL_CALLBACKS[_REQ_SUCCESS_NAME[0]], [cb]) - - def test_add_supports_multiple_distinct_callbacks(self): - cb1 = lambda options: [] # noqa: E731 - cb2 = lambda options: [] # noqa: E731 - self.assertTrue(StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], cb1)) - self.assertTrue(StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], cb2)) - self.assertEqual( - statsbeat_utils._ADDITIONAL_CALLBACKS[_REQ_SUCCESS_NAME[0]], - [cb1, cb2], - ) - - # ---- _iter_additional_observations ---- + # ---- _get_additional_observations ---- - def test_iter_unregistered_name_yields_nothing(self): - self.assertEqual(list(_iter_additional_observations(_REQ_SUCCESS_NAME[0], None)), []) + def test_get_unregistered_name_returns_empty(self): + self.assertEqual(_get_additional_observations(_REQ_SUCCESS_NAME[0], None), []) - def test_iter_yields_observations_from_registered_callback(self): + def test_get_returns_observations_from_registered_callback(self): obs = Observation(7, {"endpoint": "ep1"}) def cb(_options): yield obs - StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], cb) - self.assertEqual(list(_iter_additional_observations(_REQ_SUCCESS_NAME[0], None)), [obs]) + self._register(_REQ_SUCCESS_NAME[0], cb) + self.assertEqual(_get_additional_observations(_REQ_SUCCESS_NAME[0], None), [obs]) - def test_iter_aggregates_across_multiple_callbacks(self): + def test_get_aggregates_across_multiple_callbacks(self): obs1 = Observation(1, {"endpoint": "ep1"}) obs2 = Observation(2, {"endpoint": "ep2"}) - StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], lambda _options: [obs1]) - StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], lambda _options: [obs2]) + self._register(_REQ_SUCCESS_NAME[0], lambda _options: [obs1]) + self._register(_REQ_SUCCESS_NAME[0], lambda _options: [obs2]) self.assertEqual( - list(_iter_additional_observations(_REQ_SUCCESS_NAME[0], None)), + _get_additional_observations(_REQ_SUCCESS_NAME[0], None), [obs1, obs2], ) - def test_iter_swallows_callback_exception_and_continues(self): + def test_get_swallows_callback_exception_and_continues(self): good_obs = Observation(42, {"endpoint": "ok"}) def bad_cb(_options): raise RuntimeError("boom") - StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], bad_cb) - StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], lambda _options: [good_obs]) + self._register(_REQ_SUCCESS_NAME[0], bad_cb) + self._register(_REQ_SUCCESS_NAME[0], lambda _options: [good_obs]) # Should not raise; should still emit the good observation. self.assertEqual( - list(_iter_additional_observations(_REQ_SUCCESS_NAME[0], None)), + _get_additional_observations(_REQ_SUCCESS_NAME[0], None), [good_obs], ) - def test_iter_callbacks_for_other_metrics_not_invoked(self): + def test_get_callbacks_for_other_metrics_not_invoked(self): called = [] - StatsbeatManager().add_metric_callback(_REQ_FAILURE_NAME[0], lambda _options: called.append("failure") or []) - list(_iter_additional_observations(_REQ_SUCCESS_NAME[0], None)) + self._register(_REQ_FAILURE_NAME[0], lambda _options: called.append("failure") or []) + _get_additional_observations(_REQ_SUCCESS_NAME[0], None) self.assertEqual(called, []) # ---- integration with built-in callbacks ---- @@ -1069,7 +1052,7 @@ def test_success_count_callback_emits_extras(self): _REQUESTS_MAP[_REQ_SUCCESS_NAME[1]] = 5 extra = Observation(99, {"endpoint": "extra-ep", "statusCode": 200}) - StatsbeatManager().add_metric_callback(_REQ_SUCCESS_NAME[0], lambda _options: [extra]) + self._register(_REQ_SUCCESS_NAME[0], lambda _options: [extra]) observations = metric._get_success_count(options=None) @@ -1092,7 +1075,7 @@ def test_extras_for_other_metric_do_not_leak_into_success(self): _REQUESTS_MAP[_REQ_SUCCESS_NAME[1]] = 1 unrelated = Observation(123, {"endpoint": "other"}) - StatsbeatManager().add_metric_callback(_REQ_FAILURE_NAME[0], lambda _options: [unrelated]) + self._register(_REQ_FAILURE_NAME[0], lambda _options: [unrelated]) observations = metric._get_success_count(options=None) From f223d9c8a43306ebbd987a6717f4e79894789975 Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Tue, 16 Jun 2026 14:43:03 -0700 Subject: [PATCH 10/13] Add api files --- .../api.md | 183 ++++++++++++++++++ .../api.metadata.yml | 3 + 2 files changed, 186 insertions(+) create mode 100644 sdk/monitor/azure-monitor-opentelemetry-exporter/api.md create mode 100644 sdk/monitor/azure-monitor-opentelemetry-exporter/api.metadata.yml diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/api.md b/sdk/monitor/azure-monitor-opentelemetry-exporter/api.md new file mode 100644 index 000000000000..ffa8878c7e30 --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/api.md @@ -0,0 +1,183 @@ +```py +namespace azure.monitor.opentelemetry.exporter + + class azure.monitor.opentelemetry.exporter.ApplicationInsightsSampler(Sampler): + + def __init__(self, sampling_ratio: float = 1.0): ... + + def get_description(self) -> str: ... + + def should_sample( + self, + parent_context: Optional[Context], + trace_id: int, + name: str, + kind: Optional[SpanKind] = None, + attributes: Attributes = None, + links: Optional[Sequence[Link]] = None, + trace_state: Optional[TraceState] = None + ) -> SamplingResult: ... + + + class azure.monitor.opentelemetry.exporter.AzureMonitorLogExporter(BaseExporter, LogRecordExporter): + + def __init__( + self, + *, + api_version: Optional[str] = ..., + connection_string: Optional[str] = ..., + credential: Optional[ManagedIdentityCredential/ClientSecretCredential] = ..., + disable_offline_storage: Optional[bool] = ..., + max_envelopes_per_second: Optional[int] = ..., + storage_directory: Optional[str] = ..., + **kwargs: Any + ) -> None: ... + + @classmethod + def from_connection_string( + cls, + conn_str: str, + *, + api_version: Optional[str] = ..., + **kwargs: Any + ) -> AzureMonitorLogExporter: ... + + def export( + self, + batch: Sequence[ReadableLogRecord], + **kwargs: Any + ) -> LogRecordExportResult: ... + + def shutdown(self) -> None: ... + + + class azure.monitor.opentelemetry.exporter.AzureMonitorMetricExporter(BaseExporter, MetricExporter): + + def __init__(self, **kwargs: Any) -> None: ... + + @classmethod + def from_connection_string( + cls, + conn_str: str, + *, + api_version: Optional[str] = ..., + **kwargs: Any + ) -> AzureMonitorMetricExporter: ... + + def export( + self, + metrics_data: OTMetricsData, + timeout_millis: float = 10000, + **kwargs: Any + ) -> MetricExportResult: ... + + def force_flush(self, timeout_millis: float = 10000) -> bool: ... + + def shutdown( + self, + timeout_millis: float = 30000, + **kwargs: Any + ) -> None: ... + + + class azure.monitor.opentelemetry.exporter.AzureMonitorTraceExporter(BaseExporter, SpanExporter): + + def __init__(self, **kwargs: Any): ... + + @classmethod + def from_connection_string( + cls, + conn_str: str, + *, + api_version: Optional[str] = ..., + **kwargs: Any + ) -> AzureMonitorTraceExporter: ... + + def export( + self, + spans: Sequence[ReadableSpan], + **_kwargs: Any + ) -> SpanExportResult: ... + + def shutdown(self) -> None: ... + + + class azure.monitor.opentelemetry.exporter.RateLimitedSampler(Sampler): + + def __init__(self, target_spans_per_second_limit: float): ... + + def get_description(self) -> str: ... + + def should_sample( + self, + parent_context: Optional[Context], + trace_id: int, + name: str, + kind: Optional[SpanKind] = None, + attributes: Attributes = None, + links: Optional[Sequence[Link]] = None, + trace_state: Optional[TraceState] = None + ) -> SamplingResult: ... + + +namespace azure.monitor.opentelemetry.exporter.statsbeat + + def azure.monitor.opentelemetry.exporter.statsbeat.collect_statsbeat_metrics(exporter: BaseExporter) -> None: ... + + + def azure.monitor.opentelemetry.exporter.statsbeat.shutdown_statsbeat_metrics() -> bool: ... + + + class azure.monitor.opentelemetry.exporter.statsbeat.StatsbeatConfig: + + def __eq__(self, other: object) -> bool: ... + + def __hash__(self) -> int: ... + + def __init__( + self, + endpoint: str, + region: str, + instrumentation_key: str, + disable_offline_storage: bool = False, + credential: Optional[Any] = None, + distro_version: Optional[str] = None, + connection_string: Optional[str] = None + ) -> None: ... + + @classmethod + def from_config( + cls, + base_config: StatsbeatConfig, + config_dict: Dict[str, str] + ) -> Optional[StatsbeatConfig]: ... + + @classmethod + def from_exporter(cls, exporter: Any) -> Optional[StatsbeatConfig]: ... + + + class azure.monitor.opentelemetry.exporter.statsbeat.StatsbeatManager(metaclass=Singleton): + + def __init__(self) -> None: ... + + def get_current_config(self) -> Optional[StatsbeatConfig]: ... + + def initialize(self, config: StatsbeatConfig) -> bool: ... + + def is_initialized(self) -> bool: ... + + def shutdown(self) -> bool: ... + + +namespace azure.monitor.opentelemetry.exporter.statsbeat.customer + + def azure.monitor.opentelemetry.exporter.statsbeat.customer.collect_customer_sdkstats(exporter: BaseExporter) -> None: ... + + + def azure.monitor.opentelemetry.exporter.statsbeat.customer.get_customer_stats_manager() -> CustomerSdkStatsManager: ... + + + def azure.monitor.opentelemetry.exporter.statsbeat.customer.shutdown_customer_sdkstats_metrics() -> None: ... + + +``` \ No newline at end of file diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/api.metadata.yml b/sdk/monitor/azure-monitor-opentelemetry-exporter/api.metadata.yml new file mode 100644 index 000000000000..b4d22b0534a0 --- /dev/null +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/api.metadata.yml @@ -0,0 +1,3 @@ +apiMdSha256: 4e626c08830ccebb3dab6dab00472543831b70bae5cd17f3bf1432d938991f5b +parserVersion: 0.3.28 +pythonVersion: 3.13.14 From 8413be1039ae301e870c96644266d9c855d7bf1f Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Tue, 16 Jun 2026 16:55:48 -0700 Subject: [PATCH 11/13] Fix format --- .../monitor/opentelemetry/exporter/statsbeat/_manager.py | 2 -- .../azure/monitor/opentelemetry/exporter/statsbeat/_utils.py | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) 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 index 8183868992f1..b322d36f8cf0 100644 --- 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 @@ -383,5 +383,3 @@ def is_initialized(self) -> bool: """ with self._lock: return self._initialized - - 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 bc60dcc99090..938353ebb718 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 @@ -184,7 +184,9 @@ def _get_additional_observations(metric_name: str, options: CallbackOptions) -> :rtype: list[~opentelemetry.metrics.Observation] """ # Lazy import to avoid a circular import between _manager and _utils. - from azure.monitor.opentelemetry.exporter.statsbeat._manager import StatsbeatManager # pylint: disable=import-outside-toplevel + from azure.monitor.opentelemetry.exporter.statsbeat._manager import ( # pylint: disable=import-outside-toplevel + StatsbeatManager, + ) callbacks = StatsbeatManager()._additional_callbacks.get(metric_name, ()) # pylint: disable=protected-access From ad9f948b4c9798f899b3b6621792ba9e2ac05c23 Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Thu, 18 Jun 2026 09:20:19 -0700 Subject: [PATCH 12/13] Address feedback --- .../exporter/statsbeat/_manager.py | 33 +++++++++++++++++-- .../exporter/statsbeat/_utils.py | 7 ++-- .../tests/statsbeat/test_metrics.py | 4 +-- 3 files changed, 35 insertions(+), 9 deletions(-) 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 index b322d36f8cf0..3fea11c08c6c 100644 --- 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 @@ -162,11 +162,38 @@ def __init__(self) -> None: # Set during first initialization, preserved in shutdown for potential re-initialization self._config: Optional[StatsbeatConfig] = None # type: ignore - # Extra observation callbacks contributed by SDKs/distros. Keyed by built-in - # statsbeat metric name. Registered directly on the singleton instance, e.g. - # ``StatsbeatManager()._additional_callbacks.setdefault(name, []).append(cb)``. + # Extra observation callbacks contributed by SDKs/distros. self._additional_callbacks: Dict[str, List[Callable[[CallbackOptions], Iterable[Observation]]]] = {} + def add_additional_metric_callbacks( + self, + metric_name: str, + callback: Callable[[CallbackOptions], Iterable[Observation]], + ) -> None: + """Register additional callbacks for a built-in statsbeat metric. + + :param metric_name: Name of the built-in statsbeat metric. + :type metric_name: str + :param callback: Callback that yields observations for the metric. + :type callback: Callable[[~opentelemetry.metrics.CallbackOptions], Iterable[~opentelemetry.metrics.Observation]] + """ + callbacks = self._additional_callbacks.setdefault(metric_name, []) + if callback not in callbacks: + callbacks.append(callback) + + def get_additional_metric_callbacks( + self, + metric_name: str, + ) -> Iterable[Callable[[CallbackOptions], Iterable[Observation]]]: + """Return registered callbacks for a built-in statsbeat metric. + + :param metric_name: Name of the built-in statsbeat metric. + :type metric_name: str + :return: Registered callbacks for the provided metric name. + :rtype: Iterable[Callable[[~opentelemetry.metrics.CallbackOptions], Iterable[~opentelemetry.metrics.Observation]]] # pylint: disable=line-too-long + """ + return self._additional_callbacks.get(metric_name, ()) + @staticmethod def _validate_config(config: Optional[StatsbeatConfig]) -> bool: """Validate that a configuration has all required fields. 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 938353ebb718..1feb90359d54 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 @@ -172,9 +172,8 @@ def _get_additional_observations(metric_name: str, options: CallbackOptions) -> """Return observations contributed by extra callbacks registered on :class:`StatsbeatManager`. Invoked by the built-in ``_StatsbeatMetrics`` callbacks at collection time. - Reads ``StatsbeatManager()._additional_callbacks`` (a live mutable dict on the - singleton instance), which SDKs/distros populate directly. Exceptions raised by - individual callbacks are caught, logged, and skipped. + Reads callbacks registered on the singleton :class:`StatsbeatManager`. + Exceptions raised by individual callbacks are caught, logged, and skipped. :param metric_name: Name of the built-in statsbeat metric being collected. :type metric_name: str @@ -188,7 +187,7 @@ def _get_additional_observations(metric_name: str, options: CallbackOptions) -> StatsbeatManager, ) - callbacks = StatsbeatManager()._additional_callbacks.get(metric_name, ()) # pylint: disable=protected-access + callbacks = StatsbeatManager().get_additional_metric_callbacks(metric_name) observations: List[Observation] = [] iter_logger = logging.getLogger(__name__) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py index 0e851664ba92..be3bdfad634c 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/tests/statsbeat/test_metrics.py @@ -975,7 +975,7 @@ def test_shorten_host(self): # pylint: disable=protected-access class TestAdditionalObservationCallbacks(unittest.TestCase): - """Tests for StatsbeatManager._additional_callbacks and the _get_additional_observations helper.""" + """Tests for statsbeat callback registration and _get_additional_observations.""" def setUp(self): _REQUESTS_MAP.clear() @@ -989,7 +989,7 @@ def tearDown(self): @staticmethod def _register(metric_name, callback): - StatsbeatManager()._additional_callbacks.setdefault(metric_name, []).append(callback) + StatsbeatManager().add_additional_metric_callbacks(metric_name, callback) def _make_metric(self): return _StatsbeatMetrics( From 719889ce9fc4e10113b1dde0f2de7c1f9f9b3b00 Mon Sep 17 00:00:00 2001 From: Radhika Gupta Date: Thu, 18 Jun 2026 09:25:29 -0700 Subject: [PATCH 13/13] Add api files --- sdk/monitor/azure-monitor-opentelemetry-exporter/api.md | 8 ++++++++ .../azure-monitor-opentelemetry-exporter/api.metadata.yml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/api.md b/sdk/monitor/azure-monitor-opentelemetry-exporter/api.md index ffa8878c7e30..5b1f9d356d25 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/api.md +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/api.md @@ -160,6 +160,14 @@ namespace azure.monitor.opentelemetry.exporter.statsbeat def __init__(self) -> None: ... + def add_additional_metric_callbacks( + self, + metric_name: str, + callback: Callable[[CallbackOptions], Iterable[Observation]] + ) -> None: ... + + def get_additional_metric_callbacks(self, metric_name: str) -> Iterable[Callable[[CallbackOptions], Iterable[Observation]]]: ... + def get_current_config(self) -> Optional[StatsbeatConfig]: ... def initialize(self, config: StatsbeatConfig) -> bool: ... diff --git a/sdk/monitor/azure-monitor-opentelemetry-exporter/api.metadata.yml b/sdk/monitor/azure-monitor-opentelemetry-exporter/api.metadata.yml index b4d22b0534a0..1a672ccb930f 100644 --- a/sdk/monitor/azure-monitor-opentelemetry-exporter/api.metadata.yml +++ b/sdk/monitor/azure-monitor-opentelemetry-exporter/api.metadata.yml @@ -1,3 +1,3 @@ -apiMdSha256: 4e626c08830ccebb3dab6dab00472543831b70bae5cd17f3bf1432d938991f5b +apiMdSha256: e927f060406b601099194e0e8346efaed5f68fee04cc95eb441de90f67b1b3d6 parserVersion: 0.3.28 pythonVersion: 3.13.14