33# Licensed under the MIT License. See License.txt in the project root for
44# license information.
55# -------------------------------------------------------------------------
6+ import base64
7+ import hashlib
8+ import json
69import os
710import random
811import time
2528 ValuesView ,
2629 TypeVar ,
2730)
31+ from azure .appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module
32+ ConfigurationSetting ,
33+ FeatureFlagConfigurationSetting ,
34+ )
2835from ._models import SettingSelector
2936from ._constants import (
3037 REQUEST_TRACING_DISABLED_ENVIRONMENT_VARIABLE ,
3845 PERCENTAGE_FILTER_KEY ,
3946 TIME_WINDOW_FILTER_KEY ,
4047 TARGETING_FILTER_KEY ,
48+ PERCENTAGE_FILTER_NAMES ,
49+ TIME_WINDOW_FILTER_NAMES ,
50+ TARGETING_FILTER_NAMES ,
51+ TELEMETRY_KEY ,
52+ METADATA_KEY ,
53+ ETAG_KEY ,
54+ FEATURE_FLAG_REFERENCE_KEY ,
55+ ALLOCATION_ID_KEY ,
56+ APP_CONFIG_AI_MIME_PROFILE ,
57+ APP_CONFIG_AICC_MIME_PROFILE ,
4158)
4259
4360JSON = Mapping [str , Any ]
4865
4966
5067def delay_failure (start_time : datetime .datetime ) -> None :
51- # We want to make sure we are up a minimum amount of time before we kill the process. Otherwise, we could get stuck
52- # in a quick restart loop.
68+ """
69+ We want to make sure we are up a minimum amount of time before we kill the process.
70+ Otherwise, we could get stuck in a quick restart loop.
71+
72+ :param start_time: The time when the process started.
73+ :type start_time: datetime.datetime
74+ """
5375 min_time = datetime .timedelta (seconds = min_uptime )
5476 current_time = datetime .datetime .now ()
5577 if current_time - start_time < min_time :
@@ -161,11 +183,11 @@ def is_json_content_type(content_type: str) -> bool:
161183 return False
162184
163185
164- def _build_sentinel (setting : Union [str , Tuple [str , str ]]) -> Tuple [str , str ]:
186+ def _build_watched_setting (setting : Union [str , Tuple [str , str ]]) -> Tuple [str , str ]:
165187 try :
166188 key , label = setting # type:ignore
167- except IndexError :
168- key = setting
189+ except ( IndexError , ValueError ) :
190+ key = str ( setting ) # Ensure key is a string
169191 label = NULL_CHAR
170192 if "*" in key or "*" in label :
171193 raise ValueError ("Wildcard key or label filters are not supported for refresh." )
@@ -260,7 +282,7 @@ class AzureAppConfigurationProviderBase(Mapping[str, Union[str, JSON]]): # pyli
260282 """
261283
262284 def __init__ (self , ** kwargs : Any ) -> None :
263- self ._origin_endpoint = kwargs .get ("endpoint" , None )
285+ self ._origin_endpoint : str = kwargs .get ("endpoint" , "" )
264286 self ._dict : Dict [str , Any ] = {}
265287 self ._selects : List [SettingSelector ] = kwargs .pop (
266288 "selects" , [SettingSelector (key_filter = "*" , label_filter = NULL_CHAR )]
@@ -270,7 +292,9 @@ def __init__(self, **kwargs: Any) -> None:
270292 self ._trim_prefixes : List [str ] = sorted (trim_prefixes , key = len , reverse = True )
271293
272294 refresh_on : List [Tuple [str , str ]] = kwargs .pop ("refresh_on" , None ) or []
273- self ._refresh_on : Mapping [Tuple [str , str ], Optional [str ]] = {_build_sentinel (s ): None for s in refresh_on }
295+ self ._watched_settings : Dict [Tuple [str , str ], Optional [str ]] = {
296+ _build_watched_setting (s ): None for s in refresh_on
297+ }
274298 self ._refresh_timer : _RefreshTimer = _RefreshTimer (** kwargs )
275299 self ._keyvault_credential = kwargs .pop ("keyvault_credential" , None )
276300 self ._secret_resolver = kwargs .pop ("secret_resolver" , None )
@@ -282,10 +306,10 @@ def __init__(self, **kwargs: Any) -> None:
282306 )
283307 self ._feature_flag_enabled = kwargs .pop ("feature_flag_enabled" , False )
284308 self ._feature_flag_selectors = kwargs .pop ("feature_flag_selectors" , [SettingSelector (key_filter = "*" )])
285- self ._refresh_on_feature_flags : Mapping [Tuple [str , str ], Optional [str ]] = {}
309+ self ._watched_feature_flags : Dict [Tuple [str , str ], Optional [str ]] = {}
286310 self ._feature_flag_refresh_timer : _RefreshTimer = _RefreshTimer (** kwargs )
287311 self ._feature_flag_refresh_enabled = kwargs .pop ("feature_flag_refresh_enabled" , False )
288- self ._feature_filter_usage : Mapping [str , bool ] = {}
312+ self ._feature_filter_usage : Dict [str , bool ] = {}
289313 self ._uses_load_balancing = kwargs .pop ("load_balancing_enabled" , False )
290314 self ._uses_ai_configuration = False
291315 self ._uses_aicc_configuration = False # AI Chat Completion
@@ -301,6 +325,136 @@ def _process_key_name(self, config):
301325 break
302326 return trimmed_key
303327
328+ def _update_ff_telemetry_metadata (
329+ self , endpoint : str , feature_flag : FeatureFlagConfigurationSetting , feature_flag_value : Dict
330+ ):
331+ """
332+ Add telemetry metadata to feature flag values.
333+
334+ :param endpoint: The App Configuration endpoint URL.
335+ :type endpoint: str
336+ :param feature_flag: The feature flag configuration setting.
337+ :type feature_flag: FeatureFlagConfigurationSetting
338+ :param feature_flag_value: The feature flag value dictionary to update.
339+ :type feature_flag_value: Dict[str, Any]
340+ """
341+ if TELEMETRY_KEY in feature_flag_value :
342+ if METADATA_KEY not in feature_flag_value [TELEMETRY_KEY ]:
343+ feature_flag_value [TELEMETRY_KEY ][METADATA_KEY ] = {}
344+ feature_flag_value [TELEMETRY_KEY ][METADATA_KEY ][ETAG_KEY ] = feature_flag .etag
345+
346+ if not endpoint .endswith ("/" ):
347+ endpoint += "/"
348+ feature_flag_reference = f"{ endpoint } kv/{ feature_flag .key } "
349+ if feature_flag .label and not feature_flag .label .isspace ():
350+ feature_flag_reference += f"?label={ feature_flag .label } "
351+ if feature_flag_value [TELEMETRY_KEY ].get ("enabled" ):
352+ feature_flag_value [TELEMETRY_KEY ][METADATA_KEY ][FEATURE_FLAG_REFERENCE_KEY ] = feature_flag_reference
353+ allocation_id = self ._generate_allocation_id (feature_flag_value )
354+ if allocation_id :
355+ feature_flag_value [TELEMETRY_KEY ][METADATA_KEY ][ALLOCATION_ID_KEY ] = allocation_id
356+
357+ def _update_feature_filter_telemetry (self , feature_flag : FeatureFlagConfigurationSetting ):
358+ """
359+ Track feature filter usage for App Configuration telemetry.
360+
361+ :param feature_flag: The feature flag to analyze for filter usage.
362+ :type feature_flag: FeatureFlagConfigurationSetting
363+ """
364+ if feature_flag .filters :
365+ for filter in feature_flag .filters :
366+ if filter .get ("name" ) in PERCENTAGE_FILTER_NAMES :
367+ self ._feature_filter_usage [PERCENTAGE_FILTER_KEY ] = True
368+ elif filter .get ("name" ) in TIME_WINDOW_FILTER_NAMES :
369+ self ._feature_filter_usage [TIME_WINDOW_FILTER_KEY ] = True
370+ elif filter .get ("name" ) in TARGETING_FILTER_NAMES :
371+ self ._feature_filter_usage [TARGETING_FILTER_KEY ] = True
372+ else :
373+ self ._feature_filter_usage [CUSTOM_FILTER_KEY ] = True
374+
375+ @staticmethod
376+ def _generate_allocation_id (feature_flag_value : Dict [str , JSON ]) -> Optional [str ]:
377+ """
378+ Generates an allocation ID for the specified feature.
379+ 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
380+
381+ :param Dict[str, JSON] feature_flag_value: The feature to generate an allocation ID for.
382+ :rtype: str
383+ :return: The allocation ID.
384+ """
385+
386+ allocation_id = ""
387+ allocated_variants = []
388+
389+ allocation : Optional [JSON ] = feature_flag_value .get ("allocation" )
390+
391+ if not allocation :
392+ return None
393+
394+ # Seed
395+ allocation_id = f"seed={ allocation .get ('seed' , '' )} "
396+
397+ # DefaultWhenEnabled
398+ if "default_when_enabled" in allocation :
399+ allocated_variants .append (allocation .get ("default_when_enabled" ))
400+
401+ allocation_id += f"\n default_when_enabled={ allocation .get ('default_when_enabled' , '' )} "
402+
403+ # Percentile
404+ allocation_id += "\n percentiles="
405+
406+ percentile = allocation .get ("percentile" )
407+
408+ if percentile :
409+ percentile_allocations = sorted (
410+ (x for x in percentile if x .get ("from" ) != x .get ("to" )),
411+ key = lambda x : x .get ("from" ),
412+ )
413+
414+ for percentile_allocation in percentile_allocations :
415+ if "variant" in percentile_allocation :
416+ allocated_variants .append (percentile_allocation .get ("variant" ))
417+
418+ allocation_id += ";" .join (
419+ f"{ pa .get ('from' )} ," f"{ base64 .b64encode (pa .get ('variant' ).encode ()).decode ()} ," f"{ pa .get ('to' )} "
420+ for pa in percentile_allocations
421+ )
422+
423+ if not allocated_variants and not allocation .get ("seed" ):
424+ return None
425+
426+ # Variants
427+ allocation_id += "\n variants="
428+
429+ variants_value = feature_flag_value .get ("variants" )
430+ if variants_value and (isinstance (variants_value , list ) or all (isinstance (v , dict ) for v in variants_value )):
431+ if (
432+ allocated_variants
433+ and isinstance (variants_value , list )
434+ and all (isinstance (v , dict ) for v in variants_value )
435+ ):
436+ sorted_variants : List [Dict [str , Any ]] = sorted (
437+ (v for v in variants_value if v .get ("name" ) in allocated_variants ),
438+ key = lambda v : v .get ("name" ),
439+ )
440+
441+ for v in sorted_variants :
442+ allocation_id += f"{ base64 .b64encode (v .get ('name' , '' ).encode ()).decode ()} ,"
443+ if "configuration_value" in v :
444+ allocation_id += (
445+ f"{ json .dumps (v .get ('configuration_value' , '' ), separators = (',' , ':' ), sort_keys = True )} "
446+ )
447+ allocation_id += ";"
448+ if sorted_variants :
449+ allocation_id = allocation_id [:- 1 ]
450+
451+ # Create a sha256 hash of the allocation_id
452+ hash_object = hashlib .sha256 (allocation_id .encode ())
453+ hash_digest = hash_object .digest ()
454+
455+ # Encode the first 15 bytes in base64 url
456+ return base64 .urlsafe_b64encode (hash_digest [:15 ]).decode ()
457+
304458 def __getitem__ (self , key : str ) -> Any :
305459 # pylint:disable=docstring-missing-param,docstring-missing-return,docstring-missing-rtype
306460 """
@@ -366,14 +520,81 @@ def get(self, key: str, default: Optional[Union[str, JSON, _T]] = None) -> Union
366520 """
367521 Returns the value of the specified key. If the key does not exist, returns the default value.
368522
369- :param str key: The key of the value to get.
523+ :param key: The key of the value to get.
524+ :type key: str
370525 :param default: The default value to return.
371- :type: str or None
526+ :type default: Optional[Union[ str, JSON, _T]]
372527 :return: The value of the specified key.
373- :rtype: Union[str, JSON]
528+ :rtype: Union[str, JSON, _T, None ]
374529 """
375530 with self ._update_lock :
376531 return self ._dict .get (key , default )
377532
378533 def __ne__ (self , other : Any ) -> bool :
379534 return not self == other
535+
536+ def _process_key_value_base (self , config : ConfigurationSetting ) -> Union [str , Dict [str , Any ]]:
537+ """
538+ Process configuration values that are not KeyVault references. If the content type is None, the value is
539+ returned as-is.
540+
541+ :param config: The configuration setting to process.
542+ :type config: ConfigurationSetting
543+ :return: The processed configuration value (JSON object if JSON content type, string otherwise).
544+ :rtype: Union[str, Dict[str, Any]]
545+ """
546+ if config .content_type is None :
547+ return config .value
548+ if is_json_content_type (config .content_type ) and not isinstance (config , FeatureFlagConfigurationSetting ):
549+ # Feature flags are of type json, but don't treat them as such
550+ try :
551+ if APP_CONFIG_AI_MIME_PROFILE in config .content_type :
552+ self ._uses_ai_configuration = True
553+ if APP_CONFIG_AICC_MIME_PROFILE in config .content_type :
554+ self ._uses_aicc_configuration = True
555+ return json .loads (config .value )
556+ except json .JSONDecodeError :
557+ try :
558+ # If the value is not a valid JSON, check if it has comments and remove them
559+ from ._json import remove_json_comments
560+
561+ return json .loads (remove_json_comments (config .value ))
562+ except (json .JSONDecodeError , ValueError ):
563+ # If the value is not a valid JSON, treat it like regular string value
564+ return config .value
565+ return config .value
566+
567+ def _process_feature_flag (self , feature_flag : FeatureFlagConfigurationSetting ) -> Dict [str , Any ]:
568+ feature_flag_value = json .loads (feature_flag .value )
569+ self ._update_ff_telemetry_metadata (self ._origin_endpoint , feature_flag , feature_flag_value )
570+ self ._update_feature_filter_telemetry (feature_flag )
571+ return feature_flag_value
572+
573+ def _update_watched_settings (
574+ self , configuration_settings : List [ConfigurationSetting ]
575+ ) -> Dict [Tuple [str , str ], Optional [str ]]:
576+ """
577+ Updates the etags of watched settings that are part of the configuration
578+ :param List[ConfigurationSetting] configuration_settings: The list of configuration settings to update
579+ :return: A dictionary mapping (key, label) tuples to their updated etags
580+ :rtype: Dict[Tuple[str, str], Optional[str]]
581+ """
582+ watched_settings : Dict [Tuple [str , str ], Optional [str ]] = {}
583+ for config in configuration_settings :
584+ if (config .key , config .label ) in self ._watched_settings :
585+ watched_settings [(config .key , config .label )] = config .etag
586+ return watched_settings
587+
588+ def _update_watched_feature_flags (
589+ self , feature_flags : List [FeatureFlagConfigurationSetting ]
590+ ) -> Dict [Tuple [str , str ], Optional [str ]]:
591+ """
592+ Updates the etags of watched feature flags that are part of the configuration
593+ :param List[FeatureFlagConfigurationSetting] feature_flags: The list of feature flags to update
594+ :return: A dictionary mapping (key, label) tuples to their updated etags
595+ :rtype: Dict[Tuple[str, str], Optional[str]]
596+ """
597+ watched_feature_flags : Dict [Tuple [str , str ], Optional [str ]] = {}
598+ for feature_flag in feature_flags :
599+ watched_feature_flags [(feature_flag .key , feature_flag .label )] = feature_flag .etag
600+ return watched_feature_flags
0 commit comments