Skip to content

Commit 91f7368

Browse files
authored
New OTel Integration (#54)
* New OTel Integration * fixing issues * pylint fixes * fixing mypy issues * Update _send_telemetry.py * Update _send_telemetry.py * Update dev_requirements.txt * fixing checks * Update _send_telemetry.py * Update _send_telemetry.py * trying mypy fix * Update _send_telemetry.py * Update to use accessor * remove attach_targeting_info * added tests * Adding Quart sample * Added otel to quart sample * Update requirements.txt
1 parent 4a7a64b commit 91f7368

File tree

8 files changed

+180
-11
lines changed

8 files changed

+180
-11
lines changed

dev_requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ sphinx
88
sphinx_rtd_theme
99
sphinx-toolbox
1010
myst_parser
11+
opentelemetry-api
12+
opentelemetry-sdk

featuremanagement/azuremonitor/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
# Licensed under the MIT License. See License.txt in the project root for
44
# license information.
55
# -------------------------------------------------------------------------
6-
from ._send_telemetry import publish_telemetry, track_event
6+
from ._send_telemetry import publish_telemetry, track_event, TargetingSpanProcessor
77

88

99
__all__ = [
1010
"publish_telemetry",
1111
"track_event",
12+
"TargetingSpanProcessor",
1213
]

featuremanagement/azuremonitor/_send_telemetry.py

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,39 @@
44
# license information.
55
# --------------------------------------------------------------------------
66
import logging
7-
from typing import Dict, Optional
8-
from .._models import VariantAssignmentReason, EvaluationEvent
7+
import inspect
8+
from typing import Any, Callable, Dict, Optional
9+
from .._models import VariantAssignmentReason, EvaluationEvent, TargetingContext
10+
11+
logger = logging.getLogger(__name__)
912

1013
try:
1114
from azure.monitor.events.extension import track_event as azure_monitor_track_event # type: ignore
15+
from opentelemetry.context.context import Context
16+
from opentelemetry.sdk.trace import Span, SpanProcessor
1217

1318
HAS_AZURE_MONITOR_EVENTS_EXTENSION = True
1419
except ImportError:
1520
HAS_AZURE_MONITOR_EVENTS_EXTENSION = False
16-
logging.warning(
21+
logger.warning(
1722
"azure-monitor-events-extension is not installed. Telemetry will not be sent to Application Insights."
1823
)
24+
SpanProcessor = object # type: ignore
25+
Span = object # type: ignore
26+
Context = object # type: ignore
1927

2028
FEATURE_NAME = "FeatureName"
2129
ENABLED = "Enabled"
2230
TARGETING_ID = "TargetingId"
2331
VARIANT = "Variant"
2432
REASON = "VariantAssignmentReason"
2533

34+
DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled"
35+
VERSION = "Version"
36+
VARIANT_ASSIGNMENT_PERCENTAGE = "VariantAssignmentPercentage"
37+
MICROSOFT_TARGETING_ID = "Microsoft.TargetingId"
38+
SPAN = "Span"
39+
2640
EVENT_NAME = "FeatureEvaluation"
2741

2842
EVALUATION_EVENT_VERSION = "1.1.0"
@@ -64,7 +78,7 @@ def publish_telemetry(evaluation_event: EvaluationEvent) -> None:
6478
event: Dict[str, Optional[str]] = {
6579
FEATURE_NAME: feature.name,
6680
ENABLED: str(evaluation_event.enabled),
67-
"Version": EVALUATION_EVENT_VERSION,
81+
VERSION: EVALUATION_EVENT_VERSION,
6882
}
6983

7084
reason = evaluation_event.reason
@@ -78,25 +92,64 @@ def publish_telemetry(evaluation_event: EvaluationEvent) -> None:
7892
# VariantAllocationPercentage
7993
allocation_percentage = 0
8094
if reason == VariantAssignmentReason.DEFAULT_WHEN_ENABLED:
81-
event["VariantAssignmentPercentage"] = str(100)
95+
event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(100)
8296
if feature.allocation:
8397
for allocation in feature.allocation.percentile:
8498
allocation_percentage += allocation.percentile_to - allocation.percentile_from
85-
event["VariantAssignmentPercentage"] = str(100 - allocation_percentage)
99+
event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(100 - allocation_percentage)
86100
elif reason == VariantAssignmentReason.PERCENTILE:
87101
if feature.allocation and feature.allocation.percentile:
88102
for allocation in feature.allocation.percentile:
89103
if variant and allocation.variant == variant.name:
90104
allocation_percentage += allocation.percentile_to - allocation.percentile_from
91-
event["VariantAssignmentPercentage"] = str(allocation_percentage)
105+
event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(allocation_percentage)
92106

93107
# DefaultWhenEnabled
94108
if feature.allocation and feature.allocation.default_when_enabled:
95-
event["DefaultWhenEnabled"] = feature.allocation.default_when_enabled
109+
event[DEFAULT_WHEN_ENABLED] = feature.allocation.default_when_enabled
96110

97111
if feature.telemetry:
98112
for metadata_key, metadata_value in feature.telemetry.metadata.items():
99113
if metadata_key not in event:
100114
event[metadata_key] = metadata_value
101115

102116
track_event(EVENT_NAME, evaluation_event.user, event_properties=event)
117+
118+
119+
class TargetingSpanProcessor(SpanProcessor):
120+
"""
121+
A custom SpanProcessor that attaches the targeting ID to the span and baggage when a new span is started.
122+
:keyword Callable[[], TargetingContext] targeting_context_accessor: Callback function to get the current targeting
123+
context if one isn't provided.
124+
"""
125+
126+
def __init__(self, **kwargs: Any) -> None:
127+
self._targeting_context_accessor: Optional[Callable[[], TargetingContext]] = kwargs.pop(
128+
"targeting_context_accessor", None
129+
)
130+
131+
def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None:
132+
"""
133+
Attaches the targeting ID to the span and baggage when a new span is started.
134+
135+
:param Span span: The span that was started.
136+
:param parent_context: The parent context of the span.
137+
"""
138+
if not HAS_AZURE_MONITOR_EVENTS_EXTENSION:
139+
logger.warning("Azure Monitor Events Extension is not installed.")
140+
return
141+
if self._targeting_context_accessor and callable(self._targeting_context_accessor):
142+
if inspect.iscoroutinefunction(self._targeting_context_accessor):
143+
logger.warning("Async targeting_context_accessor is not supported.")
144+
return
145+
targeting_context = self._targeting_context_accessor()
146+
if not targeting_context or not isinstance(targeting_context, TargetingContext):
147+
logger.warning(
148+
"targeting_context_accessor did not return a TargetingContext. Received type %s.",
149+
type(targeting_context),
150+
)
151+
return
152+
if not targeting_context.user_id:
153+
logger.debug("TargetingContext does not have a user ID.")
154+
return
155+
span.set_attribute(TARGETING_ID, targeting_context.user_id)

project-words.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ featuremanagerbase
1212
quickstart
1313
rtype
1414
usefixtures
15+
urandom

samples/quarty_sample.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# ------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for
4+
# license information.
5+
# -------------------------------------------------------------------------
6+
7+
import uuid
8+
import os
9+
from quart import Quart, request, session
10+
from quart.sessions import SecureCookieSessionInterface
11+
from azure.appconfiguration.provider import load
12+
from azure.identity import DefaultAzureCredential
13+
from azure.monitor.opentelemetry import configure_azure_monitor
14+
from featuremanagement.aio import FeatureManager
15+
from featuremanagement import TargetingContext
16+
from featuremanagement.azuremonitor import TargetingSpanProcessor
17+
18+
19+
# A callback for assigning a TargetingContext for both Telemetry logs and Feature Flag evaluation
20+
async def my_targeting_accessor() -> TargetingContext:
21+
session_id = ""
22+
if "Session-ID" in request.headers:
23+
session_id = request.headers["Session-ID"]
24+
return TargetingContext(user_id=session_id)
25+
26+
27+
# Configure Azure Monitor
28+
configure_azure_monitor(
29+
connection_string=os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"),
30+
span_processors=[TargetingSpanProcessor(targeting_context_accessor=my_targeting_accessor)],
31+
)
32+
33+
app = Quart(__name__)
34+
app.session_interface = SecureCookieSessionInterface()
35+
app.secret_key = os.urandom(24)
36+
37+
endpoint = os.environ.get("APPCONFIGURATION_ENDPOINT_STRING")
38+
credential = DefaultAzureCredential()
39+
40+
# Connecting to Azure App Configuration using AAD
41+
config = load(endpoint=endpoint, credential=credential, feature_flag_enabled=True, feature_flag_refresh_enabled=True)
42+
43+
# Load feature flags and set up targeting context accessor
44+
feature_manager = FeatureManager(config, targeting_context_accessor=my_targeting_accessor)
45+
46+
47+
@app.before_request
48+
async def before_request():
49+
if "session_id" not in session:
50+
session["session_id"] = str(uuid.uuid4()) # Generate a new session ID
51+
request.headers["Session-ID"] = session["session_id"]
52+
53+
54+
@app.route("/")
55+
async def hello():
56+
variant = await feature_manager.get_variant("Message")
57+
return str(variant.configuration if variant else "No variant found")
58+
59+
60+
app.run()

samples/requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@ featuremanagement
22
azure-appconfiguration-provider
33
azure-monitor-opentelemetry
44
azure-monitor-events-extension
5+
quart
6+
azure-identity

tests/requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
azure-monitor-opentelemetry
2-
azure-monitor-events-extension
2+
azure-monitor-events-extension

tests/test_send_telemetry_appinsights.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,19 @@
33
# Licensed under the MIT License. See License.txt in the project root for
44
# license information.
55
# --------------------------------------------------------------------------
6+
import logging
67
from unittest.mock import patch
78
import pytest
8-
from featuremanagement import EvaluationEvent, FeatureFlag, Variant, VariantAssignmentReason
9+
from featuremanagement import EvaluationEvent, FeatureFlag, Variant, VariantAssignmentReason, TargetingContext
910
import featuremanagement.azuremonitor._send_telemetry
11+
from featuremanagement.azuremonitor import TargetingSpanProcessor
1012

1113

1214
@pytest.mark.usefixtures("caplog")
1315
class TestSendTelemetryAppinsights:
1416

17+
user_id = None
18+
1519
def test_send_telemetry_appinsights(self):
1620
feature_flag = FeatureFlag.convert_from_json(
1721
{
@@ -211,3 +215,49 @@ def test_send_telemetry_appinsights_allocation(self):
211215
assert mock_track_event.call_args[0][1]["VariantAssignmentReason"] == "Percentile"
212216
assert mock_track_event.call_args[0][1]["VariantAssignmentPercentage"] == "25"
213217
assert "DefaultWhenEnabled" not in mock_track_event.call_args[0][1]
218+
219+
def test_targeting_span_processor(self, caplog):
220+
processor = TargetingSpanProcessor()
221+
processor.on_start(None)
222+
assert "" in caplog.text
223+
caplog.clear()
224+
225+
processor = TargetingSpanProcessor(targeting_context_accessor="not callable")
226+
processor.on_start(None)
227+
assert "" in caplog.text
228+
caplog.clear()
229+
230+
processor = TargetingSpanProcessor(targeting_context_accessor=self.bad_targeting_context_accessor)
231+
processor.on_start(None)
232+
assert (
233+
"targeting_context_accessor did not return a TargetingContext. Received type <class 'str'>." in caplog.text
234+
)
235+
caplog.clear()
236+
237+
processor = TargetingSpanProcessor(targeting_context_accessor=self.async_targeting_context_accessor)
238+
processor.on_start(None)
239+
assert "Async targeting_context_accessor is not supported." in caplog.text
240+
caplog.clear()
241+
242+
processor = TargetingSpanProcessor(targeting_context_accessor=self.accessor_callback)
243+
logging.getLogger().setLevel(logging.DEBUG)
244+
processor.on_start(None)
245+
assert "TargetingContext does not have a user ID." in caplog.text
246+
caplog.clear()
247+
248+
with patch("opentelemetry.sdk.trace.Span") as mock_span:
249+
self.user_id = "test_user"
250+
processor.on_start(mock_span)
251+
assert mock_span.set_attribute.call_args[0][0] == "TargetingId"
252+
assert mock_span.set_attribute.call_args[0][1] == "test_user"
253+
254+
self.user_id = None
255+
256+
def bad_targeting_context_accessor(self):
257+
return "not targeting context"
258+
259+
async def async_targeting_context_accessor(self):
260+
return TargetingContext(user_id=self.user_id)
261+
262+
def accessor_callback(self):
263+
return TargetingContext(user_id=self.user_id)

0 commit comments

Comments
 (0)