Skip to content

Feature/exp telemetry #58

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dev_requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ sphinx
sphinx_rtd_theme
sphinx-toolbox
myst_parser
opentelemetry-api
opentelemetry-sdk
26 changes: 23 additions & 3 deletions featuremanagement/_featuremanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,6 +15,8 @@
FEATURE_FILTER_NAME,
)

logger = logging.getLogger(__name__)


class FeatureManager(FeatureManagerBase):
"""
Expand All @@ -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):
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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 (
Expand All @@ -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:
Expand Down
16 changes: 12 additions & 4 deletions featuremanagement/_featuremanagerbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Copy link
Preview

Copilot AI Apr 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing the return type from TargetingContext to Optional[TargetingContext] may lead to unexpected None values in downstream calls. Consider returning an empty TargetingContext() as a fallback to ensure consistency.

Copilot uses AI. Check for mistakes.

"""
Builds a TargetingContext, either returns a provided context, takes the provided user_id to make a context, or
returns an empty context.
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
30 changes: 27 additions & 3 deletions featuremanagement/aio/_featuremanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,6 +16,8 @@
FEATURE_FILTER_NAME,
)

logger = logging.getLogger(__name__)


class FeatureManager(FeatureManagerBase):
"""
Expand All @@ -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):
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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 (
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion featuremanagement/azuremonitor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
79 changes: 75 additions & 4 deletions featuremanagement/azuremonitor/_send_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,38 @@
# 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"
TARGETING_ID = "TargetingId"
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"
Expand Down Expand Up @@ -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
Expand All @@ -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)
1 change: 1 addition & 0 deletions project-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ featuremanagerbase
quickstart
rtype
usefixtures
urandom
36 changes: 36 additions & 0 deletions samples/feature_variant_sample_with_targeting_accessor.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions samples/formatted_feature_flags.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@
},
{
"name": "False_Override",
"configuration_value": "The Variant False_Override overrides to True",
"status_override": "True"
}
]
Expand Down
Loading