33# Licensed under the MIT License. See License.txt in the project root for
44# license information.
55# -------------------------------------------------------------------------
6+ import json
67import time
78import random
89import hashlib
910import base64
1011from dataclasses import dataclass
11- from typing import Dict , List
12+ from typing import Dict , List , Optional , Mapping , Any
1213from azure .appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module
1314 FeatureFlagConfigurationSetting ,
1415)
2526 ETAG_KEY ,
2627 FEATURE_FLAG_REFERENCE_KEY ,
2728 FEATURE_FLAG_ID_KEY ,
29+ ALLOCATION_ID_KEY ,
2830)
2931
3032FALLBACK_CLIENT_REFRESH_EXPIRED_INTERVAL = 3600 # 1 hour in seconds
3133MINIMAL_CLIENT_REFRESH_INTERVAL = 30 # 30 seconds
3234
35+ JSON = Mapping [str , Any ]
36+
3337
3438@dataclass
3539class _ConfigurationClientWrapperBase :
3640 endpoint : str
3741
42+ @staticmethod
43+ def _generate_allocation_id (feature_flag_value : Dict [str , JSON ]) -> Optional [str ]:
44+ """
45+ Generates an allocation ID for the specified feature.
46+ seed=123abc\n default_when_enabled=Control\n percentiles=0,Control,20;20,Test,100\n variants=Control,standard;Test,special # pylint:disable=line-too-long
47+
48+ :param Dict[str, JSON] feature_flag_value: The feature to generate an allocation ID for.
49+ :rtype: str
50+ :return: The allocation ID.
51+ """
52+
53+ allocation_id = ""
54+ allocated_variants = []
55+
56+ allocation : Optional [JSON ] = feature_flag_value .get ("allocation" )
57+
58+ if allocation :
59+ # Seed
60+ allocation_id = f"seed={ allocation .get ('seed' , '' )} "
61+
62+ # DefaultWhenEnabled
63+ if "default_when_enabled" in allocation :
64+ allocated_variants .append (allocation .get ("default_when_enabled" ))
65+
66+ allocation_id += f"\n default_when_enabled={ allocation .get ('default_when_enabled' , '' )} "
67+
68+ # Percentile
69+ allocation_id += "\n percentiles="
70+
71+ percentile = allocation .get ("percentile" )
72+
73+ if percentile :
74+ percentile_allocations = sorted (
75+ (x for x in percentile if x .get ("from" ) != x .get ("to" )),
76+ key = lambda x : x .get ("from" ),
77+ )
78+
79+ for percentile_allocation in percentile_allocations :
80+ if "variant" in percentile_allocation :
81+ allocated_variants .append (percentile_allocation .get ("variant" ))
82+
83+ allocation_id += ";" .join (
84+ f"{ pa .get ('from' )} ," f"{ base64 .b64encode (pa .get ('variant' ).encode ()).decode ()} ," f"{ pa .get ('to' )} "
85+ for pa in percentile_allocations
86+ )
87+ else :
88+ allocation_id = "seed=\n default_when_enabled=\n percentiles="
89+
90+ if not allocated_variants and (not allocation or not allocation .get ("seed" )):
91+ return None
92+
93+ # Variants
94+ allocation_id += "\n variants="
95+
96+ variants_value = feature_flag_value .get ("variants" )
97+ if variants_value and (isinstance (variants_value , list ) or all (isinstance (v , dict ) for v in variants_value )):
98+ if allocated_variants :
99+ if isinstance (variants_value , list ) and all (isinstance (v , dict ) for v in variants_value ):
100+ sorted_variants : List [Dict [str , Any ]] = sorted (
101+ (v for v in variants_value if v .get ("name" ) in allocated_variants ),
102+ key = lambda v : v .get ("name" ),
103+ )
104+
105+ for v in sorted_variants :
106+ allocation_id += f"{ base64 .b64encode (v .get ('name' , '' ).encode ()).decode ()} ,"
107+ if "configuration_value" in v :
108+ allocation_id += f"{ json .dumps (v .get ('configuration_value' , '' ), separators = (',' , ':' ))} "
109+ allocation_id += ";"
110+ allocation_id = allocation_id [:- 1 ]
111+
112+ # Create a sha256 hash of the allocation_id
113+ hash_object = hashlib .sha256 (allocation_id .encode ())
114+ hash_digest = hash_object .digest ()
115+
116+ # Encode the first 15 bytes in base64 url
117+ allocation_id_hash = base64 .urlsafe_b64encode (hash_digest [:15 ]).decode ()
118+ return allocation_id_hash
119+
38120 @staticmethod
39121 def _calculate_feature_id (key , label ):
40122 basic_value = f"{ key } \n "
41123 if label and not label .isspace ():
42124 basic_value += f"{ label } "
43125 feature_flag_id_hash_bytes = hashlib .sha256 (basic_value .encode ()).digest ()
44- encoded_flag = base64 .b64encode (feature_flag_id_hash_bytes )
45- encoded_flag = encoded_flag .replace (b"+" , b"-" ).replace (b"/" , b"_" )
126+ encoded_flag = base64 .urlsafe_b64encode (feature_flag_id_hash_bytes )
46127 return encoded_flag [: encoded_flag .find (b"=" )]
47128
48129 def _feature_flag_telemetry (
@@ -58,10 +139,14 @@ def _feature_flag_telemetry(
58139 feature_flag_reference = f"{ endpoint } kv/{ feature_flag .key } "
59140 if feature_flag .label and not feature_flag .label .isspace ():
60141 feature_flag_reference += f"?label={ feature_flag .label } "
61- feature_flag_value [TELEMETRY_KEY ][METADATA_KEY ][FEATURE_FLAG_REFERENCE_KEY ] = feature_flag_reference
62- feature_flag_value [TELEMETRY_KEY ][METADATA_KEY ][FEATURE_FLAG_ID_KEY ] = self ._calculate_feature_id (
63- feature_flag .key , feature_flag .label
64- )
142+ if feature_flag_value [TELEMETRY_KEY ].get ("enabled" ):
143+ feature_flag_value [TELEMETRY_KEY ][METADATA_KEY ][FEATURE_FLAG_REFERENCE_KEY ] = feature_flag_reference
144+ feature_flag_value [TELEMETRY_KEY ][METADATA_KEY ][FEATURE_FLAG_ID_KEY ] = self ._calculate_feature_id (
145+ feature_flag .key , feature_flag .label
146+ )
147+ allocation_id = self ._generate_allocation_id (feature_flag_value )
148+ if allocation_id :
149+ feature_flag_value [TELEMETRY_KEY ][METADATA_KEY ][ALLOCATION_ID_KEY ] = allocation_id
65150
66151 def _feature_flag_appconfig_telemetry (
67152 self , feature_flag : FeatureFlagConfigurationSetting , filters_used : Dict [str , bool ]
0 commit comments