diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 798762b52654..39285664e608 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -87,7 +87,7 @@ # ServiceLabel: %App Configuration Provider # PRLabel: %App Configuration Provider -/sdk/appconfiguration/azure-appconfiguration-provider/ @mametcal @albertofori @avanigupta @mrm9084 +/sdk/appconfiguration/azure-appconfiguration-provider/ @albertofori @avanigupta @mrm9084 @rossgrambo # ServiceLabel: %Attestation # PRLabel: %Attestation diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager_base.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager_base.py index cf7870a4681f..d38b518a64db 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager_base.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager_base.py @@ -3,12 +3,13 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # ------------------------------------------------------------------------- +import json import time import random import hashlib import base64 from dataclasses import dataclass -from typing import Dict, List +from typing import Dict, List, Optional, Mapping, Any from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module FeatureFlagConfigurationSetting, ) @@ -25,24 +26,104 @@ ETAG_KEY, FEATURE_FLAG_REFERENCE_KEY, FEATURE_FLAG_ID_KEY, + ALLOCATION_ID_KEY, ) FALLBACK_CLIENT_REFRESH_EXPIRED_INTERVAL = 3600 # 1 hour in seconds MINIMAL_CLIENT_REFRESH_INTERVAL = 30 # 30 seconds +JSON = Mapping[str, Any] + @dataclass class _ConfigurationClientWrapperBase: endpoint: str + @staticmethod + def _generate_allocation_id(feature_flag_value: Dict[str, JSON]) -> Optional[str]: + """ + Generates an allocation ID for the specified feature. + seed=123abc\ndefault_when_enabled=Control\npercentiles=0,Control,20;20,Test,100\nvariants=Control,standard;Test,special # pylint:disable=line-too-long + + :param Dict[str, JSON] feature_flag_value: The feature to generate an allocation ID for. + :rtype: str + :return: The allocation ID. + """ + + allocation_id = "" + allocated_variants = [] + + allocation: Optional[JSON] = feature_flag_value.get("allocation") + + if allocation: + # Seed + allocation_id = f"seed={allocation.get('seed', '')}" + + # DefaultWhenEnabled + if "default_when_enabled" in allocation: + allocated_variants.append(allocation.get("default_when_enabled")) + + allocation_id += f"\ndefault_when_enabled={allocation.get('default_when_enabled', '')}" + + # Percentile + allocation_id += "\npercentiles=" + + percentile = allocation.get("percentile") + + if percentile: + percentile_allocations = sorted( + (x for x in percentile if x.get("from") != x.get("to")), + key=lambda x: x.get("from"), + ) + + for percentile_allocation in percentile_allocations: + if "variant" in percentile_allocation: + allocated_variants.append(percentile_allocation.get("variant")) + + allocation_id += ";".join( + f"{pa.get('from')}," f"{base64.b64encode(pa.get('variant').encode()).decode()}," f"{pa.get('to')}" + for pa in percentile_allocations + ) + else: + allocation_id = "seed=\ndefault_when_enabled=\npercentiles=" + + if not allocated_variants and (not allocation or not allocation.get("seed")): + return None + + # Variants + allocation_id += "\nvariants=" + + variants_value = feature_flag_value.get("variants") + if variants_value and (isinstance(variants_value, list) or all(isinstance(v, dict) for v in variants_value)): + if allocated_variants: + if isinstance(variants_value, list) and all(isinstance(v, dict) for v in variants_value): + sorted_variants: List[Dict[str, Any]] = sorted( + (v for v in variants_value if v.get("name") in allocated_variants), + key=lambda v: v.get("name"), + ) + + for v in sorted_variants: + allocation_id += f"{base64.b64encode(v.get('name', '').encode()).decode()}," + if "configuration_value" in v: + allocation_id += f"{json.dumps(v.get('configuration_value', ''), separators=(',', ':'))}" + allocation_id += ";" + allocation_id = allocation_id[:-1] + + # Create a sha256 hash of the allocation_id + hash_object = hashlib.sha256(allocation_id.encode()) + hash_digest = hash_object.digest() + + # Encode the first 15 bytes in base64 url + allocation_id_hash = base64.urlsafe_b64encode(hash_digest[:15]).decode() + return allocation_id_hash + @staticmethod def _calculate_feature_id(key, label): basic_value = f"{key}\n" if label and not label.isspace(): basic_value += f"{label}" feature_flag_id_hash_bytes = hashlib.sha256(basic_value.encode()).digest() - encoded_flag = base64.b64encode(feature_flag_id_hash_bytes) - encoded_flag = encoded_flag.replace(b"+", b"-").replace(b"/", b"_") + encoded_flag = base64.urlsafe_b64encode(feature_flag_id_hash_bytes) return encoded_flag[: encoded_flag.find(b"=")] def _feature_flag_telemetry( @@ -58,10 +139,14 @@ def _feature_flag_telemetry( feature_flag_reference = f"{endpoint}kv/{feature_flag.key}" if feature_flag.label and not feature_flag.label.isspace(): feature_flag_reference += f"?label={feature_flag.label}" - feature_flag_value[TELEMETRY_KEY][METADATA_KEY][FEATURE_FLAG_REFERENCE_KEY] = feature_flag_reference - feature_flag_value[TELEMETRY_KEY][METADATA_KEY][FEATURE_FLAG_ID_KEY] = self._calculate_feature_id( - feature_flag.key, feature_flag.label - ) + if feature_flag_value[TELEMETRY_KEY].get("enabled"): + feature_flag_value[TELEMETRY_KEY][METADATA_KEY][FEATURE_FLAG_REFERENCE_KEY] = feature_flag_reference + feature_flag_value[TELEMETRY_KEY][METADATA_KEY][FEATURE_FLAG_ID_KEY] = self._calculate_feature_id( + feature_flag.key, feature_flag.label + ) + allocation_id = self._generate_allocation_id(feature_flag_value) + if allocation_id: + feature_flag_value[TELEMETRY_KEY][METADATA_KEY][ALLOCATION_ID_KEY] = allocation_id def _feature_flag_appconfig_telemetry( self, feature_flag: FeatureFlagConfigurationSetting, filters_used: Dict[str, bool] diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py index 4e2c0db60cf4..fb9c61e83847 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py @@ -19,10 +19,12 @@ TELEMETRY_KEY = "telemetry" METADATA_KEY = "metadata" -ETAG_KEY = "ETag" +ALLOCATION_ID_KEY = "AllocationId" +ETAG_KEY = "ETag" FEATURE_FLAG_REFERENCE_KEY = "FeatureFlagReference" FEATURE_FLAG_ID_KEY = "FeatureFlagId" + PERCENTAGE_FILTER_NAMES = ["Percentage", "PercentageFilter", "Microsoft.Percentage", "Microsoft.PercentageFilter"] TIME_WINDOW_FILTER_NAMES = ["TimeWindow", "TimeWindowFilter", "Microsoft.TimeWindow", "Microsoft.TimeWindowFilter"] TARGETING_FILTER_NAMES = ["Targeting", "TargetingFilter", "Microsoft.Targeting", "Microsoft.TargetingFilter"]