Skip to content

Commit 9d5ebe1

Browse files
authored
Modify sdkstats manager (#47363)
* Add StatsbeatManager.add_metric_callback to let SDKs/distros add their own metric observations to built-in statsbeat metrics * Add CHANGELOG * Update CHANGELOG * Address feedback * Avoid race conditions in CI pipelines * Fix lint and format * Rename class * Retrigger CI/CD pipeline * Address feedback * Add api files * Fix format * Address feedback * Add api files
1 parent ff35565 commit 9d5ebe1

7 files changed

Lines changed: 210 additions & 3 deletions

File tree

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

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

55
### Features Added
6+
- Add `StatsbeatManager.add_metric_callback` to let SDKs/distros add their own metric
7+
observations to built-in statsbeat metrics
8+
([#47363](https://github.com/Azure/azure-sdk-for-python/pull/47363))
69

710
### Breaking Changes
811
- Customer Facing SDKStats: Renamed metric dimension attributes from snake_case/dotted to camelCase

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,14 @@ namespace azure.monitor.opentelemetry.exporter.statsbeat
160160

161161
def __init__(self) -> None: ...
162162

163+
def add_additional_metric_callbacks(
164+
self,
165+
metric_name: str,
166+
callback: Callable[[CallbackOptions], Iterable[Observation]]
167+
) -> None: ...
168+
169+
def get_additional_metric_callbacks(self, metric_name: str) -> Iterable[Callable[[CallbackOptions], Iterable[Observation]]]: ...
170+
163171
def get_current_config(self) -> Optional[StatsbeatConfig]: ...
164172

165173
def initialize(self, config: StatsbeatConfig) -> bool: ...
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
apiMdSha256: 4e626c08830ccebb3dab6dab00472543831b70bae5cd17f3bf1432d938991f5b
1+
apiMdSha256: e927f060406b601099194e0e8346efaed5f68fee04cc95eb441de90f67b1b3d6
22
parserVersion: 0.3.28
33
pythonVersion: 3.13.14

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
# Licensed under the MIT License.
33
import logging
44
import threading
5-
from typing import Optional, Any, Dict
5+
from typing import Callable, Iterable, List, Optional, Any, Dict
66

7+
from opentelemetry.metrics import CallbackOptions, Observation
78
from opentelemetry.sdk.metrics import MeterProvider
89
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
910
from opentelemetry.sdk.resources import Resource
@@ -161,6 +162,38 @@ def __init__(self) -> None:
161162
# Set during first initialization, preserved in shutdown for potential re-initialization
162163
self._config: Optional[StatsbeatConfig] = None # type: ignore
163164

165+
# Extra observation callbacks contributed by SDKs/distros.
166+
self._additional_callbacks: Dict[str, List[Callable[[CallbackOptions], Iterable[Observation]]]] = {}
167+
168+
def add_additional_metric_callbacks(
169+
self,
170+
metric_name: str,
171+
callback: Callable[[CallbackOptions], Iterable[Observation]],
172+
) -> None:
173+
"""Register additional callbacks for a built-in statsbeat metric.
174+
175+
:param metric_name: Name of the built-in statsbeat metric.
176+
:type metric_name: str
177+
:param callback: Callback that yields observations for the metric.
178+
:type callback: Callable[[~opentelemetry.metrics.CallbackOptions], Iterable[~opentelemetry.metrics.Observation]]
179+
"""
180+
callbacks = self._additional_callbacks.setdefault(metric_name, [])
181+
if callback not in callbacks:
182+
callbacks.append(callback)
183+
184+
def get_additional_metric_callbacks(
185+
self,
186+
metric_name: str,
187+
) -> Iterable[Callable[[CallbackOptions], Iterable[Observation]]]:
188+
"""Return registered callbacks for a built-in statsbeat metric.
189+
190+
:param metric_name: Name of the built-in statsbeat metric.
191+
:type metric_name: str
192+
:return: Registered callbacks for the provided metric name.
193+
:rtype: Iterable[Callable[[~opentelemetry.metrics.CallbackOptions], Iterable[~opentelemetry.metrics.Observation]]] # pylint: disable=line-too-long
194+
"""
195+
return self._additional_callbacks.get(metric_name, ())
196+
164197
@staticmethod
165198
def _validate_config(config: Optional[StatsbeatConfig]) -> bool:
166199
"""Validate that a configuration has all required fields.

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
get_statsbeat_customer_sdkstats_feature_set,
4141
get_statsbeat_browser_sdk_loader_feature_set,
4242
)
43+
from azure.monitor.opentelemetry.exporter.statsbeat._utils import (
44+
_get_additional_observations,
45+
)
4346
from azure.monitor.opentelemetry.exporter import _utils
4447

4548

@@ -379,6 +382,7 @@ def _get_success_count(self, options: CallbackOptions) -> Iterable[Observation]:
379382
if count != 0:
380383
observations.append(Observation(int(count), dict(attributes)))
381384
_REQUESTS_MAP[_REQ_SUCCESS_NAME[1]] = 0
385+
observations.extend(_get_additional_observations(_REQ_SUCCESS_NAME[0], options))
382386
return observations
383387

384388
# pylint: disable=unused-argument
@@ -393,6 +397,7 @@ def _get_failure_count(self, options: CallbackOptions) -> Iterable[Observation]:
393397
attributes["statusCode"] = code
394398
observations.append(Observation(int(count), dict(attributes)))
395399
_REQUESTS_MAP[_REQ_FAILURE_NAME[1]][code] = 0 # type: ignore
400+
observations.extend(_get_additional_observations(_REQ_FAILURE_NAME[0], options))
396401
return observations
397402

398403
# pylint: disable=unused-argument
@@ -409,6 +414,7 @@ def _get_average_duration(self, options: CallbackOptions) -> Iterable[Observatio
409414
observations.append(Observation(result * 1000, dict(attributes)))
410415
_REQUESTS_MAP[_REQ_DURATION_NAME[1]] = 0
411416
_REQUESTS_MAP["count"] = 0
417+
observations.extend(_get_additional_observations(_REQ_DURATION_NAME[0], options))
412418
return observations
413419

414420
# pylint: disable=unused-argument
@@ -423,6 +429,7 @@ def _get_retry_count(self, options: CallbackOptions) -> Iterable[Observation]:
423429
attributes["statusCode"] = code
424430
observations.append(Observation(int(count), dict(attributes)))
425431
_REQUESTS_MAP[_REQ_RETRY_NAME[1]][code] = 0 # type: ignore
432+
observations.extend(_get_additional_observations(_REQ_RETRY_NAME[0], options))
426433
return observations
427434

428435
# pylint: disable=unused-argument
@@ -437,6 +444,7 @@ def _get_throttle_count(self, options: CallbackOptions) -> Iterable[Observation]
437444
attributes["statusCode"] = code
438445
observations.append(Observation(int(count), dict(attributes)))
439446
_REQUESTS_MAP[_REQ_THROTTLE_NAME[1]][code] = 0 # type: ignore
447+
observations.extend(_get_additional_observations(_REQ_THROTTLE_NAME[0], options))
440448
return observations
441449

442450
# pylint: disable=unused-argument
@@ -451,6 +459,7 @@ def _get_exception_count(self, options: CallbackOptions) -> Iterable[Observation
451459
attributes["exceptionType"] = code
452460
observations.append(Observation(int(count), dict(attributes)))
453461
_REQUESTS_MAP[_REQ_EXCEPTION_NAME[1]][code] = 0 # type: ignore
462+
observations.extend(_get_additional_observations(_REQ_EXCEPTION_NAME[0], options))
454463
return observations
455464

456465

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

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import logging
55
import json
66
from collections.abc import Iterable # pylint: disable=import-error
7-
from typing import Optional, Dict
7+
from typing import Optional, Dict, List
8+
from opentelemetry.metrics import CallbackOptions, Observation
89

910
from azure.monitor.opentelemetry.exporter._constants import (
1011
_APPLICATIONINSIGHTS_STATS_CONNECTION_STRING_ENV_NAME,
@@ -165,3 +166,39 @@ def _get_connection_string_for_region_from_config(target_region: str, settings:
165166
"Unexpected error getting stats connection string for region '%s': %s", target_region, str(ex)
166167
)
167168
return None
169+
170+
171+
def _get_additional_observations(metric_name: str, options: CallbackOptions) -> List[Observation]:
172+
"""Return observations contributed by extra callbacks registered on :class:`StatsbeatManager`.
173+
174+
Invoked by the built-in ``_StatsbeatMetrics`` callbacks at collection time.
175+
Reads callbacks registered on the singleton :class:`StatsbeatManager`.
176+
Exceptions raised by individual callbacks are caught, logged, and skipped.
177+
178+
:param metric_name: Name of the built-in statsbeat metric being collected.
179+
:type metric_name: str
180+
:param options: OpenTelemetry callback options forwarded to each registered callback.
181+
:type options: ~opentelemetry.metrics.CallbackOptions
182+
:returns: List of observations contributed by registered callbacks.
183+
:rtype: list[~opentelemetry.metrics.Observation]
184+
"""
185+
# Lazy import to avoid a circular import between _manager and _utils.
186+
from azure.monitor.opentelemetry.exporter.statsbeat._manager import ( # pylint: disable=import-outside-toplevel
187+
StatsbeatManager,
188+
)
189+
190+
callbacks = StatsbeatManager().get_additional_metric_callbacks(metric_name)
191+
192+
observations: List[Observation] = []
193+
iter_logger = logging.getLogger(__name__)
194+
for cb in callbacks:
195+
try:
196+
observations.extend(cb(options))
197+
except Exception: # pylint: disable=broad-except
198+
iter_logger.debug(
199+
"Extra statsbeat callback %r for %r raised; skipping.",
200+
cb,
201+
metric_name,
202+
exc_info=True,
203+
)
204+
return observations

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

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
_REQ_SUCCESS_NAME,
2121
_REQ_THROTTLE_NAME,
2222
)
23+
from opentelemetry.metrics import Observation
24+
from azure.monitor.opentelemetry.exporter.statsbeat import _utils as statsbeat_utils
25+
from azure.monitor.opentelemetry.exporter.statsbeat._manager import StatsbeatManager
2326
from azure.monitor.opentelemetry.exporter.statsbeat._state import (
2427
_REQUESTS_MAP,
2528
_STATSBEAT_STATE,
@@ -35,6 +38,9 @@
3538
_AttachTypes,
3639
_RP_Names,
3740
)
41+
from azure.monitor.opentelemetry.exporter.statsbeat._utils import (
42+
_get_additional_observations,
43+
)
3844

3945

4046
class MockResponse(object):
@@ -967,4 +973,115 @@ def test_shorten_host(self):
967973
self.assertEqual(_shorten_host(url), "fakehost-5")
968974

969975

976+
# pylint: disable=protected-access
977+
class TestAdditionalObservationCallbacks(unittest.TestCase):
978+
"""Tests for statsbeat callback registration and _get_additional_observations."""
979+
980+
def setUp(self):
981+
_REQUESTS_MAP.clear()
982+
# Force a fresh StatsbeatManager so its __init__ runs again (which
983+
# rebuilds an empty _additional_callbacks dict on the instance).
984+
StatsbeatManager._instances.pop(StatsbeatManager, None)
985+
986+
def tearDown(self):
987+
_REQUESTS_MAP.clear()
988+
StatsbeatManager._instances.pop(StatsbeatManager, None)
989+
990+
@staticmethod
991+
def _register(metric_name, callback):
992+
StatsbeatManager().add_additional_metric_callbacks(metric_name, callback)
993+
994+
def _make_metric(self):
995+
return _StatsbeatMetrics(
996+
MeterProvider(),
997+
"1aa11111-bbbb-1ccc-8ddd-eeeeffff3334",
998+
"https://westus-1.in.applicationinsights.azure.com/",
999+
False,
1000+
0,
1001+
False,
1002+
)
1003+
1004+
# ---- _get_additional_observations ----
1005+
1006+
def test_get_unregistered_name_returns_empty(self):
1007+
self.assertEqual(_get_additional_observations(_REQ_SUCCESS_NAME[0], None), [])
1008+
1009+
def test_get_returns_observations_from_registered_callback(self):
1010+
obs = Observation(7, {"endpoint": "ep1"})
1011+
1012+
def cb(_options):
1013+
yield obs
1014+
1015+
self._register(_REQ_SUCCESS_NAME[0], cb)
1016+
self.assertEqual(_get_additional_observations(_REQ_SUCCESS_NAME[0], None), [obs])
1017+
1018+
def test_get_aggregates_across_multiple_callbacks(self):
1019+
obs1 = Observation(1, {"endpoint": "ep1"})
1020+
obs2 = Observation(2, {"endpoint": "ep2"})
1021+
self._register(_REQ_SUCCESS_NAME[0], lambda _options: [obs1])
1022+
self._register(_REQ_SUCCESS_NAME[0], lambda _options: [obs2])
1023+
self.assertEqual(
1024+
_get_additional_observations(_REQ_SUCCESS_NAME[0], None),
1025+
[obs1, obs2],
1026+
)
1027+
1028+
def test_get_swallows_callback_exception_and_continues(self):
1029+
good_obs = Observation(42, {"endpoint": "ok"})
1030+
1031+
def bad_cb(_options):
1032+
raise RuntimeError("boom")
1033+
1034+
self._register(_REQ_SUCCESS_NAME[0], bad_cb)
1035+
self._register(_REQ_SUCCESS_NAME[0], lambda _options: [good_obs])
1036+
# Should not raise; should still emit the good observation.
1037+
self.assertEqual(
1038+
_get_additional_observations(_REQ_SUCCESS_NAME[0], None),
1039+
[good_obs],
1040+
)
1041+
1042+
def test_get_callbacks_for_other_metrics_not_invoked(self):
1043+
called = []
1044+
self._register(_REQ_FAILURE_NAME[0], lambda _options: called.append("failure") or [])
1045+
_get_additional_observations(_REQ_SUCCESS_NAME[0], None)
1046+
self.assertEqual(called, [])
1047+
1048+
# ---- integration with built-in callbacks ----
1049+
1050+
def test_success_count_callback_emits_extras(self):
1051+
metric = self._make_metric()
1052+
_REQUESTS_MAP[_REQ_SUCCESS_NAME[1]] = 5
1053+
1054+
extra = Observation(99, {"endpoint": "extra-ep", "statusCode": 200})
1055+
self._register(_REQ_SUCCESS_NAME[0], lambda _options: [extra])
1056+
1057+
observations = metric._get_success_count(options=None)
1058+
1059+
# Built-in observation followed by the extra one.
1060+
self.assertEqual(len(observations), 2)
1061+
self.assertEqual(observations[0].value, 5)
1062+
self.assertIs(observations[-1], extra)
1063+
1064+
def test_success_count_callback_unchanged_without_extras(self):
1065+
metric = self._make_metric()
1066+
_REQUESTS_MAP[_REQ_SUCCESS_NAME[1]] = 3
1067+
1068+
observations = metric._get_success_count(options=None)
1069+
1070+
self.assertEqual(len(observations), 1)
1071+
self.assertEqual(observations[0].value, 3)
1072+
1073+
def test_extras_for_other_metric_do_not_leak_into_success(self):
1074+
metric = self._make_metric()
1075+
_REQUESTS_MAP[_REQ_SUCCESS_NAME[1]] = 1
1076+
1077+
unrelated = Observation(123, {"endpoint": "other"})
1078+
self._register(_REQ_FAILURE_NAME[0], lambda _options: [unrelated])
1079+
1080+
observations = metric._get_success_count(options=None)
1081+
1082+
self.assertEqual(len(observations), 1)
1083+
self.assertEqual(observations[0].value, 1)
1084+
self.assertNotIn(unrelated, observations)
1085+
1086+
9701087
# cSpell:enable

0 commit comments

Comments
 (0)