3
3
# Licensed under the MIT License. See License.txt in the project root for
4
4
# license information.
5
5
# -------------------------------------------------------------------------
6
+ import base64
7
+ import hashlib
8
+ import json
6
9
import os
7
10
import random
8
11
import time
25
28
ValuesView ,
26
29
TypeVar ,
27
30
)
31
+ from azure .appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module
32
+ ConfigurationSetting ,
33
+ FeatureFlagConfigurationSetting ,
34
+ )
28
35
from ._models import SettingSelector
29
36
from ._constants import (
30
37
REQUEST_TRACING_DISABLED_ENVIRONMENT_VARIABLE ,
38
45
PERCENTAGE_FILTER_KEY ,
39
46
TIME_WINDOW_FILTER_KEY ,
40
47
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 ,
41
58
)
42
59
43
60
JSON = Mapping [str , Any ]
48
65
49
66
50
67
def 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
+ """
53
75
min_time = datetime .timedelta (seconds = min_uptime )
54
76
current_time = datetime .datetime .now ()
55
77
if current_time - start_time < min_time :
@@ -161,11 +183,11 @@ def is_json_content_type(content_type: str) -> bool:
161
183
return False
162
184
163
185
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 ]:
165
187
try :
166
188
key , label = setting # type:ignore
167
- except IndexError :
168
- key = setting
189
+ except ( IndexError , ValueError ) :
190
+ key = str ( setting ) # Ensure key is a string
169
191
label = NULL_CHAR
170
192
if "*" in key or "*" in label :
171
193
raise ValueError ("Wildcard key or label filters are not supported for refresh." )
@@ -260,7 +282,7 @@ class AzureAppConfigurationProviderBase(Mapping[str, Union[str, JSON]]): # pyli
260
282
"""
261
283
262
284
def __init__ (self , ** kwargs : Any ) -> None :
263
- self ._origin_endpoint = kwargs .get ("endpoint" , None )
285
+ self ._origin_endpoint : str = kwargs .get ("endpoint" , "" )
264
286
self ._dict : Dict [str , Any ] = {}
265
287
self ._selects : List [SettingSelector ] = kwargs .pop (
266
288
"selects" , [SettingSelector (key_filter = "*" , label_filter = NULL_CHAR )]
@@ -270,7 +292,9 @@ def __init__(self, **kwargs: Any) -> None:
270
292
self ._trim_prefixes : List [str ] = sorted (trim_prefixes , key = len , reverse = True )
271
293
272
294
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
+ }
274
298
self ._refresh_timer : _RefreshTimer = _RefreshTimer (** kwargs )
275
299
self ._keyvault_credential = kwargs .pop ("keyvault_credential" , None )
276
300
self ._secret_resolver = kwargs .pop ("secret_resolver" , None )
@@ -282,10 +306,10 @@ def __init__(self, **kwargs: Any) -> None:
282
306
)
283
307
self ._feature_flag_enabled = kwargs .pop ("feature_flag_enabled" , False )
284
308
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 ]] = {}
286
310
self ._feature_flag_refresh_timer : _RefreshTimer = _RefreshTimer (** kwargs )
287
311
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 ] = {}
289
313
self ._uses_load_balancing = kwargs .pop ("load_balancing_enabled" , False )
290
314
self ._uses_ai_configuration = False
291
315
self ._uses_aicc_configuration = False # AI Chat Completion
@@ -301,6 +325,136 @@ def _process_key_name(self, config):
301
325
break
302
326
return trimmed_key
303
327
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
+
304
458
def __getitem__ (self , key : str ) -> Any :
305
459
# pylint:disable=docstring-missing-param,docstring-missing-return,docstring-missing-rtype
306
460
"""
@@ -366,14 +520,81 @@ def get(self, key: str, default: Optional[Union[str, JSON, _T]] = None) -> Union
366
520
"""
367
521
Returns the value of the specified key. If the key does not exist, returns the default value.
368
522
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
370
525
:param default: The default value to return.
371
- :type: str or None
526
+ :type default: Optional[Union[ str, JSON, _T]]
372
527
:return: The value of the specified key.
373
- :rtype: Union[str, JSON]
528
+ :rtype: Union[str, JSON, _T, None ]
374
529
"""
375
530
with self ._update_lock :
376
531
return self ._dict .get (key , default )
377
532
378
533
def __ne__ (self , other : Any ) -> bool :
379
534
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