Skip to content

Commit 2fc8535

Browse files
authored
Implement Customer Facing Statsbeat (#41669)
* Implementation for customer facing statsbeat * Updated test cases * Updated CHANGELOG with PR number * Changes for successful items * Deleted obselete test files * Fixed _init_.py and reverted changes * Commented out tests * Fixed spelling error * Disabled spell check * Removed storage changes for now * Updated CHANGELOG * Fixed test failures * Fixed failing tests * Fixed linting errors * Fixed newline lint error * Addressed review comments * Added new line in CHANGELOG
1 parent d97182a commit 2fc8535

File tree

5 files changed

+329
-2
lines changed

5 files changed

+329
-2
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
- Detect synthetically created telemetry based on the user-agent header
88
([#41733](https://github.com/Azure/azure-sdk-for-python/pull/41733))
9+
- Added customer-facing statsbeat preview.
10+
([#41669](https://github.com/Azure/azure-sdk-for-python/pull/41669))
911

1012
### Breaking Changes
1113

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

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
# Licensed under the MIT License.
33
# cSpell:disable
44

5+
from enum import Enum
56
from opentelemetry.semconv.metrics import MetricInstruments
67
from opentelemetry.semconv.metrics.http_metrics import (
78
HTTP_CLIENT_REQUEST_DURATION,
89
HTTP_SERVER_REQUEST_DURATION,
910
)
11+
from azure.core import CaseInsensitiveEnumMeta
1012

1113
# Environment variables
1214

@@ -62,14 +64,18 @@
6264
_MESSAGE_ENVELOPE_NAME = "Microsoft.ApplicationInsights.Message"
6365
_REQUEST_ENVELOPE_NAME = "Microsoft.ApplicationInsights.Request"
6466
_REMOTE_DEPENDENCY_ENVELOPE_NAME = "Microsoft.ApplicationInsights.RemoteDependency"
67+
_REMOTE_DEPENDENCY_ENVELOPE_DATA = "Microsoft.ApplicationInsights.RemoteDependencyData"
68+
_EVENT_ENVELOPE_NAME = "Microsoft.ApplicationInsights.Event"
69+
_PAGE_VIEW_ENVELOPE_NAME = "Microsoft.ApplicationInsights.PageView"
70+
_PERFORMANCE_COUNTER_ENVELOPE_NAME = "Microsoft.ApplicationInsights.PerformanceCounter"
71+
_AVAILABILITY_ENVELOPE_NAME = "Microsoft.ApplicationInsights.Availability"
6572

6673
# Feature constants
6774
_APPLICATION_INSIGHTS_EVENT_MARKER_ATTRIBUTE = "APPLICATION_INSIGHTS_EVENT_MARKER_ATTRIBUTE"
6875
_AZURE_MONITOR_DISTRO_VERSION_ARG = "distro_version"
6976
_MICROSOFT_CUSTOM_EVENT_NAME = "microsoft.custom_event.name"
7077

7178
# Statsbeat
72-
7379
# (OpenTelemetry metric name, Statsbeat metric name)
7480
_ATTACH_METRIC_NAME = ("attach", "Attach")
7581
_FEATURE_METRIC_NAME = ("feature", "Feature")
@@ -115,6 +121,69 @@
115121
"ukwest",
116122
]
117123

124+
# Telemetry Types
125+
_AVAILABILITY = "AVAILABILITY"
126+
_CUSTOM_EVENT = "CUSTOM_EVENT"
127+
_CUSTOM_METRIC = "CUSTOM_METRIC"
128+
_DEPENDENCY = "DEPENDENCY"
129+
_EXCEPTION = "EXCEPTION"
130+
_PAGE_VIEW = "PAGE_VIEW"
131+
_PERFORMANCE_COUNTER = "PERFORMANCE_COUNTER"
132+
_REQUEST = "REQUEST"
133+
_TRACE = "TRACE"
134+
_UNKNOWN = "UNKNOWN"
135+
136+
# Customer Facing Statsbeat
137+
_APPLICATIONINSIGHTS_STATSBEAT_ENABLED_PREVIEW = "APPLICATIONINSIGHTS_STATSBEAT_ENABLED_PREVIEW"
138+
139+
_CUSTOMER_STATSBEAT_LANGUAGE = "python"
140+
141+
class DropCode(str, Enum, metaclass=CaseInsensitiveEnumMeta):
142+
CLIENT_READONLY = "CLIENT_READONLY"
143+
CLIENT_EXCEPTION = "CLIENT_EXCEPTION"
144+
CLIENT_STALE_DATA = "CLIENT_STALE_DATA"
145+
CLIENT_PERSISTENCE_CAPACITY = "CLIENT_PERSISTENCE_CAPACITY"
146+
UNKNOWN = "UNKNOWN"
147+
148+
class RetryCode(str, Enum, metaclass=CaseInsensitiveEnumMeta):
149+
CLIENT_TIMEOUT = "CLIENT_TIMEOUT"
150+
UNKNOWN = "UNKNOWN"
151+
152+
class CustomerStatsbeatMetricName(str, Enum, metaclass=CaseInsensitiveEnumMeta):
153+
ITEM_SUCCESS_COUNT = "preview.item.success.count"
154+
ITEM_DROP_COUNT = "preview.item.dropped.count"
155+
ITEM_RETRY_COUNT = "preview.item.retry.count"
156+
157+
class CustomerStatsbeatProperties:
158+
language: str
159+
version: str
160+
compute_type: str
161+
def __init__(self, language: str, version: str, compute_type: str):
162+
self.language = language
163+
self.version = version
164+
self.compute_type = compute_type
165+
166+
## Map from Azure Monitor envelope names to TelemetryType
167+
_TYPE_MAP = {
168+
_EVENT_ENVELOPE_NAME: _CUSTOM_EVENT,
169+
_METRIC_ENVELOPE_NAME: _CUSTOM_METRIC,
170+
_REMOTE_DEPENDENCY_ENVELOPE_DATA: _DEPENDENCY,
171+
_EXCEPTION_ENVELOPE_NAME: _EXCEPTION,
172+
_PAGE_VIEW_ENVELOPE_NAME: _PAGE_VIEW,
173+
_MESSAGE_ENVELOPE_NAME: _TRACE,
174+
_REQUEST_ENVELOPE_NAME: _REQUEST,
175+
_PERFORMANCE_COUNTER_ENVELOPE_NAME: _PERFORMANCE_COUNTER,
176+
_AVAILABILITY_ENVELOPE_NAME: _AVAILABILITY,
177+
}
178+
179+
# Map RP names
180+
class _RP_Names(Enum):
181+
APP_SERVICE = "appsvc"
182+
FUNCTIONS = "functions"
183+
AKS = "aks"
184+
VM = "vm"
185+
UNKNOWN = "unknown"
186+
118187
# Instrumentations
119188

120189
# Special constant for azure-sdk opentelemetry instrumentation

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@
2828
_PYTHON_APPLICATIONINSIGHTS_ENABLE_TELEMETRY,
2929
_WEBSITE_SITE_NAME,
3030
)
31-
31+
from azure.monitor.opentelemetry.exporter._constants import (
32+
_TYPE_MAP,
33+
_UNKNOWN,
34+
_RP_Names,
35+
)
3236

3337
opentelemetry_version = ""
3438

@@ -380,3 +384,19 @@ def __call__(cls, *args, **kwargs):
380384
if not cls._instance:
381385
cls._instance = super(Singleton, cls).__call__(*args, **kwargs)
382386
return cls._instance
387+
388+
def _get_telemetry_type(item: TelemetryItem):
389+
if hasattr(item, "data") and item.data is not None:
390+
base_type = getattr(item.data, "base_type", None)
391+
if base_type:
392+
return _TYPE_MAP.get(base_type, _UNKNOWN)
393+
return _UNKNOWN
394+
395+
def get_compute_type():
396+
if _is_on_functions():
397+
return _RP_Names.FUNCTIONS.value
398+
if _is_on_app_service():
399+
return _RP_Names.APP_SERVICE.value
400+
if _is_on_aks():
401+
return _RP_Names.AKS.value
402+
return _RP_Names.UNKNOWN.value
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
"""Customer Statsbeat implementation for Azure Monitor OpenTelemetry Exporter.
4+
5+
This module provides the implementation for collecting and reporting customer statsbeat
6+
metrics that track the usage and performance of the Azure Monitor OpenTelemetry Exporter.
7+
"""
8+
9+
from typing import List, Dict, Any, Iterable
10+
import os
11+
12+
from opentelemetry.metrics import CallbackOptions, Observation
13+
from opentelemetry.sdk.metrics import MeterProvider
14+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
15+
16+
from azure.monitor.opentelemetry.exporter._constants import (
17+
_APPLICATIONINSIGHTS_STATSBEAT_ENABLED_PREVIEW,
18+
_DEFAULT_STATS_SHORT_EXPORT_INTERVAL,
19+
CustomerStatsbeatProperties,
20+
#DropCode,
21+
#RetryCode,
22+
CustomerStatsbeatMetricName,
23+
_CUSTOMER_STATSBEAT_LANGUAGE,
24+
)
25+
26+
from azure.monitor.opentelemetry.exporter.statsbeat._exporter import AzureMonitorMetricExporter
27+
from azure.monitor.opentelemetry.exporter._utils import (
28+
Singleton,
29+
get_compute_type,
30+
)
31+
32+
from azure.monitor.opentelemetry.exporter import VERSION
33+
34+
class _CustomerStatsbeatTelemetryCounters:
35+
def __init__(self):
36+
self.total_item_success_count: Dict[str, Any] = {}
37+
38+
class CustomerStatsbeatMetrics(metaclass=Singleton):
39+
def __init__(self, options):
40+
self._counters = _CustomerStatsbeatTelemetryCounters()
41+
self._language = _CUSTOMER_STATSBEAT_LANGUAGE
42+
self._is_enabled = os.environ.get(_APPLICATIONINSIGHTS_STATSBEAT_ENABLED_PREVIEW, "").lower() in ("true")
43+
if not self._is_enabled:
44+
return
45+
46+
exporter_config = {
47+
"connection_string": options.connection_string,
48+
}
49+
self._customer_statsbeat_exporter = AzureMonitorMetricExporter(**exporter_config)
50+
metric_reader_options = {
51+
"exporter": self._customer_statsbeat_exporter,
52+
"export_interval_millis": _DEFAULT_STATS_SHORT_EXPORT_INTERVAL
53+
}
54+
self._customer_statsbeat_metric_reader = PeriodicExportingMetricReader(**metric_reader_options)
55+
self._customer_statsbeat_meter_provider = MeterProvider(
56+
metric_readers=[self._customer_statsbeat_metric_reader]
57+
)
58+
self._customer_statsbeat_meter = self._customer_statsbeat_meter_provider.get_meter(__name__)
59+
self._customer_properties = CustomerStatsbeatProperties(
60+
language=self._language,
61+
version=VERSION,
62+
compute_type=get_compute_type(),
63+
)
64+
self._success_gauge = self._customer_statsbeat_meter.create_observable_gauge(
65+
name=CustomerStatsbeatMetricName.ITEM_SUCCESS_COUNT.value,
66+
description="Tracks successful telemetry items sent to Azure Monitor",
67+
callbacks=[self._item_success_callback]
68+
)
69+
70+
def count_successful_items(self, count: int, telemetry_type: str) -> None:
71+
if not self._is_enabled or count <= 0:
72+
return
73+
if telemetry_type in self._counters.total_item_success_count:
74+
self._counters.total_item_success_count[telemetry_type] += count
75+
else:
76+
self._counters.total_item_success_count[telemetry_type] = count
77+
78+
def _item_success_callback(self, options: CallbackOptions) -> Iterable[Observation]: # pylint: disable=unused-argument
79+
if not getattr(self, "_is_enabled", False):
80+
return []
81+
82+
observations: List[Observation] = []
83+
for telemetry_type, count in self._counters.total_item_success_count.items():
84+
attributes = {
85+
"language": self._customer_properties.language,
86+
"version": self._customer_properties.version,
87+
"compute_type": self._customer_properties.compute_type,
88+
"telemetry_type": telemetry_type
89+
}
90+
observations.append(Observation(count, dict(attributes)))
91+
92+
return observations
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import os
5+
import unittest
6+
from unittest import mock
7+
import random
8+
import time
9+
import types
10+
import copy
11+
12+
# Import directly from module to avoid circular imports
13+
from azure.monitor.opentelemetry.exporter.statsbeat._customer_statsbeat import CustomerStatsbeatMetrics
14+
15+
from azure.monitor.opentelemetry.exporter._constants import (
16+
_APPLICATIONINSIGHTS_STATSBEAT_ENABLED_PREVIEW,
17+
_REQUEST,
18+
_DEPENDENCY,
19+
_REQ_RETRY_NAME,
20+
_CUSTOMER_STATSBEAT_LANGUAGE,
21+
_DEFAULT_STATS_SHORT_EXPORT_INTERVAL,
22+
_UNKNOWN,
23+
_TYPE_MAP,
24+
)
25+
26+
from opentelemetry import trace
27+
from opentelemetry.sdk.resources import Resource
28+
from opentelemetry.sdk.trace import TracerProvider
29+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
30+
from azure.monitor.opentelemetry.exporter.export._base import (
31+
BaseExporter,
32+
ExportResult,
33+
)
34+
from azure.monitor.opentelemetry.exporter import AzureMonitorTraceExporter
35+
from azure.monitor.opentelemetry.exporter.statsbeat._state import _REQUESTS_MAP
36+
37+
class TestCustomerStatsbeat(unittest.TestCase):
38+
"""Tests for CustomerStatsbeatMetrics."""
39+
def setUp(self):
40+
_REQUESTS_MAP.clear()
41+
# Clear singleton instance for test isolation
42+
CustomerStatsbeatMetrics._instance = None
43+
44+
self.env_patcher = mock.patch.dict(os.environ, {
45+
"APPLICATIONINSIGHTS_STATSBEAT_ENABLED_PREVIEW": "true"
46+
})
47+
self.env_patcher.start()
48+
self.mock_options = mock.Mock()
49+
self.mock_options.instrumentation_key = "363331ca-f431-4119-bdcd-31a75920f958"
50+
self.mock_options.network_collection_interval = _DEFAULT_STATS_SHORT_EXPORT_INTERVAL
51+
self.mock_options.connection_string = "InstrumentationKey=363331ca-f431-4119-bdcd-31a75920f958;IngestionEndpoint=https://eastus-8.in.applicationinsights.azure.com/"
52+
self.mock_options.language = _CUSTOMER_STATSBEAT_LANGUAGE
53+
self.original_trace_provider = trace._TRACER_PROVIDER
54+
trace._TRACER_PROVIDER = None
55+
self.mock_options.metrics = CustomerStatsbeatMetrics(self.mock_options)
56+
self.mock_options.transmit_called = [False]
57+
58+
def tearDown(self):
59+
self.env_patcher.stop()
60+
# Restore trace provider
61+
trace._TRACER_PROVIDER = self.original_trace_provider
62+
# Clean up singleton instances to prevent cross-test contamination
63+
if hasattr(self.mock_options, 'metrics') and self.mock_options.metrics:
64+
metrics = self.mock_options.metrics
65+
if hasattr(metrics, '_customer_statsbeat_metric_reader'):
66+
try:
67+
# Shutdown to prevent additional periodic exports
68+
metrics._customer_statsbeat_metric_reader.shutdown()
69+
except Exception:
70+
pass # Ignore shutdown errors
71+
CustomerStatsbeatMetrics._instance = None
72+
73+
def test_successful_item_count(self):
74+
successful_dependencies = 0
75+
76+
metrics = self.mock_options.metrics
77+
metrics._counters.total_item_success_count.clear()
78+
79+
exporter = AzureMonitorTraceExporter(connection_string=self.mock_options.connection_string)
80+
81+
def patched_transmit(self_exporter, envelopes):
82+
self.mock_options.transmit_called[0] = True
83+
84+
for envelope in envelopes:
85+
if not hasattr(envelope, "data") or not envelope.data:
86+
continue
87+
88+
if envelope.data.base_type == "RemoteDependencyData":
89+
base_data = envelope.data.base_data
90+
if base_data and hasattr(base_data, "success"):
91+
if base_data.success:
92+
envelope_name = "Microsoft.ApplicationInsights." + envelope.data.base_type
93+
metrics.count_successful_items(1, _TYPE_MAP.get(envelope_name, _UNKNOWN))
94+
95+
return ExportResult.SUCCESS
96+
97+
exporter._transmit = types.MethodType(patched_transmit, exporter)
98+
99+
resource = Resource.create({"service.name": "dependency-test", "service.instance.id": "test-instance"})
100+
trace_provider = TracerProvider(resource=resource)
101+
102+
processor = SimpleSpanProcessor(exporter)
103+
trace_provider.add_span_processor(processor)
104+
105+
tracer = trace_provider.get_tracer(__name__)
106+
107+
# Generate random number of dependencies (between 5 and 15)
108+
total_dependencies = random.randint(5, 15)
109+
110+
for i in range(total_dependencies):
111+
success = random.choice([True, False])
112+
if success:
113+
successful_dependencies += 1
114+
115+
with tracer.start_as_current_span(
116+
name=f"{'success' if success else 'failed'}-dependency-{i}",
117+
kind=trace.SpanKind.CLIENT,
118+
attributes={
119+
"db.system": "mysql",
120+
"db.name": "test_db",
121+
"db.operation": "query",
122+
"net.peer.name": "test-db-server",
123+
"net.peer.port": 3306,
124+
"http.status_code": 200 if success else random.choice([400, 500, 503])
125+
}
126+
) as span:
127+
span.set_status(trace.Status(
128+
trace.StatusCode.OK if success else trace.StatusCode.ERROR
129+
))
130+
time.sleep(0.1)
131+
132+
trace_provider.force_flush()
133+
134+
self.metrics_instance = metrics
135+
136+
self.assertTrue(self.mock_options.transmit_called[0], "Exporter _transmit method was not called")
137+
138+
actual_count = metrics._counters.total_item_success_count.get(_DEPENDENCY, 0)
139+
140+
self.assertEqual(
141+
actual_count,
142+
successful_dependencies,
143+
f"Expected {successful_dependencies} successful dependencies, got {actual_count}"
144+
)

0 commit comments

Comments
 (0)