diff --git a/dev_requirements.txt b/dev_requirements.txt index 2eff0bb..b5fba0b 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -8,3 +8,5 @@ sphinx sphinx_rtd_theme sphinx-toolbox myst_parser +opentelemetry-api +opentelemetry-sdk diff --git a/featuremanagement/_featuremanager.py b/featuremanagement/_featuremanager.py index 0a48da8..49a4cf6 100644 --- a/featuremanagement/_featuremanager.py +++ b/featuremanagement/_featuremanager.py @@ -3,7 +3,8 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # ------------------------------------------------------------------------- -from typing import cast, overload, Any, Optional, Dict, Mapping, List +import logging +from typing import cast, overload, Any, Optional, Dict, Mapping, List, Tuple from ._defaultfilters import TimeWindowFilter, TargetingFilter from ._featurefilters import FeatureFilter from ._models import EvaluationEvent, Variant, TargetingContext @@ -14,6 +15,8 @@ FEATURE_FILTER_NAME, ) +logger = logging.getLogger(__name__) + class FeatureManager(FeatureManagerBase): """ @@ -23,6 +26,8 @@ class FeatureManager(FeatureManagerBase): :keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags. :keyword Callable[EvaluationEvent] on_feature_evaluated: Callback function to be called when a feature flag is evaluated. + :keyword Callable[[], TargetingContext] targeting_context_accessor: Callback function to get the current targeting + context if one isn't provided. """ def __init__(self, configuration: Mapping[str, Any], **kwargs: Any): @@ -56,7 +61,7 @@ def is_enabled(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> bool: :return: True if the feature flag is enabled for the given context. :rtype: bool """ - targeting_context = self._build_targeting_context(args) + targeting_context: TargetingContext = self._build_targeting_context(args) result = self._check_feature(feature_flag_id, targeting_context, **kwargs) if ( @@ -89,7 +94,7 @@ def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> Option :return: Variant instance. :rtype: Variant """ - targeting_context = self._build_targeting_context(args) + targeting_context: TargetingContext = self._build_targeting_context(args) result = self._check_feature(feature_flag_id, targeting_context, **kwargs) if ( @@ -102,6 +107,21 @@ def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> Option self._on_feature_evaluated(result) return result.variant + def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext: + targeting_context = super()._build_targeting_context(args) + if targeting_context: + return targeting_context + if not targeting_context and self._targeting_context_accessor and callable(self._targeting_context_accessor): + targeting_context = self._targeting_context_accessor() + if targeting_context and isinstance(targeting_context, TargetingContext): + return targeting_context + logger.warning( + "targeting_context_accessor did not return a TargetingContext. Received type %s.", + type(targeting_context), + ) + + return TargetingContext() + def _check_feature_filters( self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext, **kwargs: Any ) -> None: diff --git a/featuremanagement/_featuremanagerbase.py b/featuremanagement/_featuremanagerbase.py index 5078caf..845db2c 100644 --- a/featuremanagement/_featuremanagerbase.py +++ b/featuremanagement/_featuremanagerbase.py @@ -6,7 +6,7 @@ import hashlib import logging from abc import ABC -from typing import List, Optional, Dict, Tuple, Any, Mapping +from typing import List, Optional, Dict, Tuple, Any, Mapping, Callable from ._models import FeatureFlag, Variant, VariantAssignmentReason, TargetingContext, EvaluationEvent, VariantReference @@ -21,6 +21,9 @@ FEATURE_FILTER_PARAMETERS = "parameters" +logger = logging.getLogger(__name__) + + def _get_feature_flag(configuration: Mapping[str, Any], feature_flag_name: str) -> Optional[FeatureFlag]: """ Gets the FeatureFlag json from the configuration, if it exists it gets converted to a FeatureFlag object. @@ -77,6 +80,9 @@ def __init__(self, configuration: Mapping[str, Any], **kwargs: Any): self._cache: Dict[str, Optional[FeatureFlag]] = {} self._copy = configuration.get(FEATURE_MANAGEMENT_KEY) self._on_feature_evaluated = kwargs.pop("on_feature_evaluated", None) + self._targeting_context_accessor: Optional[Callable[[], TargetingContext]] = kwargs.pop( + "targeting_context_accessor", None + ) @staticmethod def _assign_default_disabled_variant(evaluation_event: EvaluationEvent) -> None: @@ -218,7 +224,7 @@ def _variant_name_to_variant(self, feature_flag: FeatureFlag, variant_name: Opti return Variant(variant_reference.name, variant_reference.configuration_value) return None - def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext: + def _build_targeting_context(self, args: Tuple[Any]) -> Optional[TargetingContext]: """ Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or returns an empty context. @@ -229,10 +235,12 @@ def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext: if len(args) == 1: arg = args[0] if isinstance(arg, str): + # If the user_id is provided, return a TargetingContext with the user_id return TargetingContext(user_id=arg, groups=[]) if isinstance(arg, TargetingContext): + # If a TargetingContext is provided, return it return arg - return TargetingContext() + return None def _assign_allocation(self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext) -> None: feature_flag = evaluation_event.feature @@ -271,7 +279,7 @@ def _check_feature_base(self, feature_flag_id: str) -> Tuple[EvaluationEvent, bo evaluation_event = EvaluationEvent(feature_flag) if not feature_flag: - logging.warning("Feature flag %s not found", feature_flag_id) + logger.warning("Feature flag %s not found", feature_flag_id) # Unknown feature flags are disabled by default return evaluation_event, True diff --git a/featuremanagement/aio/_featuremanager.py b/featuremanagement/aio/_featuremanager.py index d2cce03..d5f45a7 100644 --- a/featuremanagement/aio/_featuremanager.py +++ b/featuremanagement/aio/_featuremanager.py @@ -4,7 +4,8 @@ # license information. # ------------------------------------------------------------------------- import inspect -from typing import cast, overload, Any, Optional, Dict, Mapping, List +import logging +from typing import cast, overload, Any, Optional, Dict, Mapping, List, Tuple from ._defaultfilters import TimeWindowFilter, TargetingFilter from ._featurefilters import FeatureFilter from .._models import EvaluationEvent, Variant, TargetingContext @@ -15,6 +16,8 @@ FEATURE_FILTER_NAME, ) +logger = logging.getLogger(__name__) + class FeatureManager(FeatureManagerBase): """ @@ -24,6 +27,8 @@ class FeatureManager(FeatureManagerBase): :keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags. :keyword Callable[EvaluationEvent] on_feature_evaluated: Callback function to be called when a feature flag is evaluated. + :keyword Callable[[], TargetingContext] targeting_context_accessor: Callback function to get the current targeting + context if one isn't provided. """ def __init__(self, configuration: Mapping[str, Any], **kwargs: Any): @@ -57,7 +62,7 @@ async def is_enabled(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> b :return: True if the feature flag is enabled for the given context. :rtype: bool """ - targeting_context = self._build_targeting_context(args) + targeting_context: TargetingContext = await self._build_targeting_context_async(args) result = await self._check_feature(feature_flag_id, targeting_context, **kwargs) if ( @@ -93,7 +98,7 @@ async def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> :return: Variant instance. :rtype: Variant """ - targeting_context = self._build_targeting_context(args) + targeting_context: TargetingContext = await self._build_targeting_context_async(args) result = await self._check_feature(feature_flag_id, targeting_context, **kwargs) if ( @@ -109,6 +114,25 @@ async def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> self._on_feature_evaluated(result) return result.variant + async def _build_targeting_context_async(self, args: Tuple[Any]) -> TargetingContext: + targeting_context = super()._build_targeting_context(args) + if targeting_context: + return targeting_context + if not targeting_context and self._targeting_context_accessor and callable(self._targeting_context_accessor): + + if inspect.iscoroutinefunction(self._targeting_context_accessor): + # If a targeting_context_accessor is provided, return the TargetingContext from it + targeting_context = await self._targeting_context_accessor() + else: + targeting_context = self._targeting_context_accessor() + if targeting_context and isinstance(targeting_context, TargetingContext): + return targeting_context + logger.warning( + "targeting_context_accessor did not return a TargetingContext. Received type %s.", + type(targeting_context), + ) + return TargetingContext() + async def _check_feature_filters( self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext, **kwargs: Any ) -> None: diff --git a/featuremanagement/azuremonitor/__init__.py b/featuremanagement/azuremonitor/__init__.py index 22099f5..138bcfb 100644 --- a/featuremanagement/azuremonitor/__init__.py +++ b/featuremanagement/azuremonitor/__init__.py @@ -3,10 +3,11 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # ------------------------------------------------------------------------- -from ._send_telemetry import publish_telemetry, track_event +from ._send_telemetry import publish_telemetry, track_event, TargetingSpanProcessor __all__ = [ "publish_telemetry", "track_event", + "TargetingSpanProcessor", ] diff --git a/featuremanagement/azuremonitor/_send_telemetry.py b/featuremanagement/azuremonitor/_send_telemetry.py index fccc94e..2e8f6ea 100644 --- a/featuremanagement/azuremonitor/_send_telemetry.py +++ b/featuremanagement/azuremonitor/_send_telemetry.py @@ -4,18 +4,26 @@ # license information. # -------------------------------------------------------------------------- import logging -from typing import Dict, Optional -from .._models import EvaluationEvent +import inspect +from typing import Any, Callable, Dict, Optional +from .._models import VariantAssignmentReason, EvaluationEvent, TargetingContext + +logger = logging.getLogger(__name__) try: from azure.monitor.events.extension import track_event as azure_monitor_track_event # type: ignore + from opentelemetry.context.context import Context + from opentelemetry.sdk.trace import Span, SpanProcessor HAS_AZURE_MONITOR_EVENTS_EXTENSION = True except ImportError: HAS_AZURE_MONITOR_EVENTS_EXTENSION = False - logging.warning( + logger.warning( "azure-monitor-events-extension is not installed. Telemetry will not be sent to Application Insights." ) + SpanProcessor = object # type: ignore + Span = object # type: ignore + Context = object # type: ignore FEATURE_NAME = "FeatureName" ENABLED = "Enabled" @@ -23,6 +31,11 @@ VARIANT = "Variant" REASON = "VariantAssignmentReason" +DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled" +VERSION = "Version" +VARIANT_ASSIGNMENT_PERCENTAGE = "VariantAssignmentPercentage" +MICROSOFT_TARGETING_ID = "Microsoft.TargetingId" + EVENT_NAME = "FeatureEvaluation" EVALUATION_EVENT_VERSION = "1.0.0" @@ -64,7 +77,7 @@ def publish_telemetry(evaluation_event: EvaluationEvent) -> None: event: Dict[str, Optional[str]] = { FEATURE_NAME: feature.name, ENABLED: str(evaluation_event.enabled), - "Version": EVALUATION_EVENT_VERSION, + VERSION: EVALUATION_EVENT_VERSION, } reason = evaluation_event.reason @@ -75,9 +88,67 @@ def publish_telemetry(evaluation_event: EvaluationEvent) -> None: if variant: event[VARIANT] = variant.name + # VariantAllocationPercentage + allocation_percentage = 0 + if reason == VariantAssignmentReason.DEFAULT_WHEN_ENABLED: + event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(100) + if feature.allocation: + for allocation in feature.allocation.percentile: + allocation_percentage += allocation.percentile_to - allocation.percentile_from + event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(100 - allocation_percentage) + elif reason == VariantAssignmentReason.PERCENTILE: + if feature.allocation and feature.allocation.percentile: + for allocation in feature.allocation.percentile: + if variant and allocation.variant == variant.name: + allocation_percentage += allocation.percentile_to - allocation.percentile_from + event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(allocation_percentage) + + # DefaultWhenEnabled + if feature.allocation and feature.allocation.default_when_enabled: + event[DEFAULT_WHEN_ENABLED] = feature.allocation.default_when_enabled + if feature.telemetry: for metadata_key, metadata_value in feature.telemetry.metadata.items(): if metadata_key not in event: event[metadata_key] = metadata_value track_event(EVENT_NAME, evaluation_event.user, event_properties=event) + + +class TargetingSpanProcessor(SpanProcessor): + """ + A custom SpanProcessor that attaches the targeting ID to the span and baggage when a new span is started. + :keyword Callable[[], TargetingContext] targeting_context_accessor: Callback function to get the current targeting + context if one isn't provided. + """ + + def __init__(self, **kwargs: Any) -> None: + self._targeting_context_accessor: Optional[Callable[[], TargetingContext]] = kwargs.pop( + "targeting_context_accessor", None + ) + + def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None: + """ + Attaches the targeting ID to the span and baggage when a new span is started. + + :param Span span: The span that was started. + :param parent_context: The parent context of the span. + """ + if not HAS_AZURE_MONITOR_EVENTS_EXTENSION: + logger.warning("Azure Monitor Events Extension is not installed.") + return + if self._targeting_context_accessor and callable(self._targeting_context_accessor): + if inspect.iscoroutinefunction(self._targeting_context_accessor): + logger.warning("Async targeting_context_accessor is not supported.") + return + targeting_context = self._targeting_context_accessor() + if not targeting_context or not isinstance(targeting_context, TargetingContext): + logger.warning( + "targeting_context_accessor did not return a TargetingContext. Received type %s.", + type(targeting_context), + ) + return + if not targeting_context.user_id: + logger.debug("TargetingContext does not have a user ID.") + return + span.set_attribute(TARGETING_ID, targeting_context.user_id) diff --git a/project-words.txt b/project-words.txt index 20f4119..ea9f1a6 100644 --- a/project-words.txt +++ b/project-words.txt @@ -12,3 +12,4 @@ featuremanagerbase quickstart rtype usefixtures +urandom diff --git a/samples/feature_variant_sample_with_targeting_accessor.py b/samples/feature_variant_sample_with_targeting_accessor.py new file mode 100644 index 0000000..481642c --- /dev/null +++ b/samples/feature_variant_sample_with_targeting_accessor.py @@ -0,0 +1,36 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +import json +import os +import sys +from random_filter import RandomFilter +from featuremanagement import FeatureManager, TargetingContext + + +script_directory = os.path.dirname(os.path.abspath(sys.argv[0])) + +with open(script_directory + "/formatted_feature_flags.json", "r", encoding="utf-8") as f: + feature_flags = json.load(f) + +USER_ID = "Adam" + + +def my_targeting_accessor() -> TargetingContext: + return TargetingContext(user_id=USER_ID) + + +feature_manager = FeatureManager( + feature_flags, feature_filters=[RandomFilter()], targeting_context_accessor=my_targeting_accessor +) + +print(feature_manager.is_enabled("TestVariants")) +print(feature_manager.get_variant("TestVariants").configuration) + +USER_ID = "Ellie" + +print(feature_manager.is_enabled("TestVariants")) +print(feature_manager.get_variant("TestVariants").configuration) diff --git a/samples/formatted_feature_flags.json b/samples/formatted_feature_flags.json index f3f278f..941ab1d 100644 --- a/samples/formatted_feature_flags.json +++ b/samples/formatted_feature_flags.json @@ -218,6 +218,7 @@ }, { "name": "False_Override", + "configuration_value": "The Variant False_Override overrides to True", "status_override": "True" } ] diff --git a/samples/quarty_sample.py b/samples/quarty_sample.py new file mode 100644 index 0000000..8bfd08e --- /dev/null +++ b/samples/quarty_sample.py @@ -0,0 +1,60 @@ +# ------------------------------------------------------------------------ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# ------------------------------------------------------------------------- + +import uuid +import os +from quart import Quart, request, session +from quart.sessions import SecureCookieSessionInterface +from azure.appconfiguration.provider import load +from azure.identity import DefaultAzureCredential +from azure.monitor.opentelemetry import configure_azure_monitor +from featuremanagement.aio import FeatureManager +from featuremanagement import TargetingContext +from featuremanagement.azuremonitor import TargetingSpanProcessor + + +# A callback for assigning a TargetingContext for both Telemetry logs and Feature Flag evaluation +async def my_targeting_accessor() -> TargetingContext: + session_id = "" + if "Session-ID" in request.headers: + session_id = request.headers["Session-ID"] + return TargetingContext(user_id=session_id) + + +# Configure Azure Monitor +configure_azure_monitor( + connection_string=os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"), + span_processors=[TargetingSpanProcessor(targeting_context_accessor=my_targeting_accessor)], +) + +app = Quart(__name__) +app.session_interface = SecureCookieSessionInterface() +app.secret_key = os.urandom(24) + +endpoint = os.environ.get("APPCONFIGURATION_ENDPOINT_STRING") +credential = DefaultAzureCredential() + +# Connecting to Azure App Configuration using AAD +config = load(endpoint=endpoint, credential=credential, feature_flag_enabled=True, feature_flag_refresh_enabled=True) + +# Load feature flags and set up targeting context accessor +feature_manager = FeatureManager(config, targeting_context_accessor=my_targeting_accessor) + + +@app.before_request +async def before_request(): + if "session_id" not in session: + session["session_id"] = str(uuid.uuid4()) # Generate a new session ID + request.headers["Session-ID"] = session["session_id"] + + +@app.route("/") +async def hello(): + variant = await feature_manager.get_variant("Message") + return str(variant.configuration if variant else "No variant found") + + +app.run() diff --git a/samples/requirements.txt b/samples/requirements.txt index f4673f1..1c84dca 100644 --- a/samples/requirements.txt +++ b/samples/requirements.txt @@ -2,3 +2,5 @@ featuremanagement azure-appconfiguration-provider azure-monitor-opentelemetry azure-monitor-events-extension +quart +azure-identity diff --git a/tests/requirements.txt b/tests/requirements.txt index b91565e..f52da80 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,2 +1,2 @@ azure-monitor-opentelemetry -azure-monitor-events-extension +azure-monitor-events-extension \ No newline at end of file diff --git a/tests/test_default_feature_flags.py b/tests/test_default_feature_flags.py index 6910ea9..7e4345a 100644 --- a/tests/test_default_feature_flags.py +++ b/tests/test_default_feature_flags.py @@ -261,3 +261,52 @@ def test_feature_manager_requirement_type(self): # The second TimeWindow filter failed assert not feature_manager.is_enabled("Beta") assert feature_manager.is_enabled("Gamma") + + def test_feature_manager_with_targeting_accessor(self): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Target", + "enabled": "true", + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "Users": ["Adam"], + "Groups": [{"Name": "Stage1", "RolloutPercentage": 100}], + "DefaultRolloutPercentage": 50, + "Exclusion": {"Users": [], "Groups": []}, + } + }, + } + ] + }, + }, + ] + } + } + + user_id = "Adam" + group_id = None + + def my_targeting_accessor() -> TargetingContext: + if group_id: + return TargetingContext(user_id=user_id, groups=[group_id]) + return TargetingContext(user_id=user_id) + + feature_manager = FeatureManager(feature_flags, targeting_context_accessor=my_targeting_accessor) + assert feature_manager is not None + # Adam is in the user audience + assert feature_manager.is_enabled("Target") + # Belle is not part of the 50% or default 50% of users + user_id = "Belle" + assert not feature_manager.is_enabled("Target") + # Belle is enabled because all of Stage 1 is enabled + group_id = "Stage1" + assert feature_manager.is_enabled("Target") + # Belle is not enabled because he is not in Stage 2, group isn't looked at when user is targeted + group_id = "Stage2" + assert not feature_manager.is_enabled("Target") diff --git a/tests/test_default_feature_flags_async.py b/tests/test_default_feature_flags_async.py index a44ce47..c8d2048 100644 --- a/tests/test_default_feature_flags_async.py +++ b/tests/test_default_feature_flags_async.py @@ -266,3 +266,53 @@ async def test_feature_manager_requirement_type(self): # The second TimeWindow filter failed assert not await feature_manager.is_enabled("Beta") assert await feature_manager.is_enabled("Gamma") + + @pytest.mark.asyncio + async def test_feature_manager_with_targeting_accessor(self): + feature_flags = { + "feature_management": { + "feature_flags": [ + { + "id": "Target", + "enabled": "true", + "conditions": { + "client_filters": [ + { + "name": "Microsoft.Targeting", + "parameters": { + "Audience": { + "Users": ["Adam"], + "Groups": [{"Name": "Stage1", "RolloutPercentage": 100}], + "DefaultRolloutPercentage": 50, + "Exclusion": {"Users": [], "Groups": []}, + } + }, + } + ] + }, + }, + ] + } + } + + user_id = "Adam" + group_id = None + + def my_targeting_accessor() -> TargetingContext: + if group_id: + return TargetingContext(user_id=user_id, groups=[group_id]) + return TargetingContext(user_id=user_id) + + feature_manager = FeatureManager(feature_flags, targeting_context_accessor=my_targeting_accessor) + assert feature_manager is not None + # Adam is in the user audience + assert await feature_manager.is_enabled("Target") + # Belle is not part of the 50% or default 50% of users + user_id = "Belle" + assert not await feature_manager.is_enabled("Target") + # Belle is enabled because all of Stage 1 is enabled + group_id = "Stage1" + assert await feature_manager.is_enabled("Target") + # Belle is not enabled because he is not in Stage 2, group isn't looked at when user is targeted + group_id = "Stage2" + assert not await feature_manager.is_enabled("Target") diff --git a/tests/test_send_telemetry_appinsights.py b/tests/test_send_telemetry_appinsights.py index 0260754..52eab4e 100644 --- a/tests/test_send_telemetry_appinsights.py +++ b/tests/test_send_telemetry_appinsights.py @@ -3,15 +3,19 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- +import logging from unittest.mock import patch import pytest -from featuremanagement import EvaluationEvent, FeatureFlag, Variant, VariantAssignmentReason +from featuremanagement import EvaluationEvent, FeatureFlag, Variant, VariantAssignmentReason, TargetingContext import featuremanagement.azuremonitor._send_telemetry +from featuremanagement.azuremonitor import TargetingSpanProcessor @pytest.mark.usefixtures("caplog") class TestSendTelemetryAppinsights: + user_id = None + def test_send_telemetry_appinsights(self): feature_flag = FeatureFlag.convert_from_json( { @@ -196,3 +200,51 @@ def test_send_telemetry_appinsights_allocation(self): assert mock_track_event.call_args[0][1]["TargetingId"] == "test_user" assert mock_track_event.call_args[0][1]["Variant"] == "big" assert mock_track_event.call_args[0][1]["VariantAssignmentReason"] == "Percentile" + assert mock_track_event.call_args[0][1]["VariantAssignmentPercentage"] == "25" + assert "DefaultWhenEnabled" not in mock_track_event.call_args[0][1] + + def test_targeting_span_processor(self, caplog): + processor = TargetingSpanProcessor() + processor.on_start(None) + assert "" in caplog.text + caplog.clear() + + processor = TargetingSpanProcessor(targeting_context_accessor="not callable") + processor.on_start(None) + assert "" in caplog.text + caplog.clear() + + processor = TargetingSpanProcessor(targeting_context_accessor=self.bad_targeting_context_accessor) + processor.on_start(None) + assert ( + "targeting_context_accessor did not return a TargetingContext. Received type ." in caplog.text + ) + caplog.clear() + + processor = TargetingSpanProcessor(targeting_context_accessor=self.async_targeting_context_accessor) + processor.on_start(None) + assert "Async targeting_context_accessor is not supported." in caplog.text + caplog.clear() + + processor = TargetingSpanProcessor(targeting_context_accessor=self.accessor_callback) + logging.getLogger().setLevel(logging.DEBUG) + processor.on_start(None) + assert "TargetingContext does not have a user ID." in caplog.text + caplog.clear() + + with patch("opentelemetry.sdk.trace.Span") as mock_span: + self.user_id = "test_user" + processor.on_start(mock_span) + assert mock_span.set_attribute.call_args[0][0] == "TargetingId" + assert mock_span.set_attribute.call_args[0][1] == "test_user" + + self.user_id = None + + def bad_targeting_context_accessor(self): + return "not targeting context" + + async def async_targeting_context_accessor(self): + return TargetingContext(user_id=self.user_id) + + def accessor_callback(self): + return TargetingContext(user_id=self.user_id)