diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py index d60e65cd32e4..e9ae3b3b65f7 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_client_manager.py @@ -163,21 +163,27 @@ def load_feature_flags( loaded_feature_flags = [] # Needs to be removed unknown keyword argument for list_configuration_settings kwargs.pop("sentinel_keys", None) + endpoint = self._client._impl._config.endpoint # pylint: disable=protected-access filters_used: Dict[str, bool] = {} for select in feature_flag_selectors: feature_flags = self._client.list_configuration_settings( key_filter=FEATURE_FLAG_PREFIX + select.key_filter, label_filter=select.label_filter, **kwargs ) for feature_flag in feature_flags: - loaded_feature_flags.append(json.loads(feature_flag.value)) if not isinstance(feature_flag, FeatureFlagConfigurationSetting): # If the feature flag is not a FeatureFlagConfigurationSetting, it means it was selected by # mistake, so we should ignore it. continue + feature_flag_value = json.loads(feature_flag.value) + + self._feature_flag_telemetry(endpoint, feature_flag, feature_flag_value) + self._feature_flag_appconfig_telemetry(feature_flag, filters_used) + + loaded_feature_flags.append(feature_flag_value) + if feature_flag_refresh_enabled: feature_flag_sentinel_keys[(feature_flag.key, feature_flag.label)] = feature_flag.etag - self._feature_flag_telemetry(feature_flag, filters_used) return loaded_feature_flags, feature_flag_sentinel_keys, filters_used @distributed_trace 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 72704f759050..cf7870a4681f 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 @@ -5,6 +5,8 @@ # ------------------------------------------------------------------------- import time import random +import hashlib +import base64 from dataclasses import dataclass from typing import Dict, List from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module @@ -18,6 +20,11 @@ PERCENTAGE_FILTER_KEY, TIME_WINDOW_FILTER_KEY, TARGETING_FILTER_KEY, + TELEMETRY_KEY, + METADATA_KEY, + ETAG_KEY, + FEATURE_FLAG_REFERENCE_KEY, + FEATURE_FLAG_ID_KEY, ) FALLBACK_CLIENT_REFRESH_EXPIRED_INTERVAL = 3600 # 1 hour in seconds @@ -28,7 +35,37 @@ class _ConfigurationClientWrapperBase: endpoint: str - def _feature_flag_telemetry(self, feature_flag: FeatureFlagConfigurationSetting, filters_used: Dict[str, bool]): + @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"_") + return encoded_flag[: encoded_flag.find(b"=")] + + def _feature_flag_telemetry( + self, endpoint: str, feature_flag: FeatureFlagConfigurationSetting, feature_flag_value: Dict + ): + if TELEMETRY_KEY in feature_flag_value: + if METADATA_KEY not in feature_flag_value[TELEMETRY_KEY]: + feature_flag_value[TELEMETRY_KEY][METADATA_KEY] = {} + feature_flag_value[TELEMETRY_KEY][METADATA_KEY][ETAG_KEY] = feature_flag.etag + + if not endpoint.endswith("/"): + endpoint += "/" + 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 + ) + + def _feature_flag_appconfig_telemetry( + self, feature_flag: FeatureFlagConfigurationSetting, filters_used: Dict[str, bool] + ): if feature_flag.filters: for filter in feature_flag.filters: if filter.get("name") in PERCENTAGE_FILTER_NAMES: @@ -51,7 +88,7 @@ def __init__( replica_discovery_enabled, min_backoff_sec, max_backoff_sec, - **kwargs + **kwargs, ): self._replica_clients: List[_ConfigurationClientWrapperBase] = [] self._original_endpoint = endpoint 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 63777eeec1a8..deece5dde40b 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py @@ -17,6 +17,11 @@ KubernetesEnvironmentVariable = "KUBERNETES_PORT" ServiceFabricEnvironmentVariable = "Fabric_NodeName" # cspell:disable-line +TELEMETRY_KEY = "telemetry" +METADATA_KEY = "metadata" +ETAG_KEY = "etag" +FEATURE_FLAG_REFERENCE_KEY = "feature_flag_reference" +FEATURE_FLAG_ID_KEY = "feature_flag_id" 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"] diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py index 966251cda7fb..6ba0fa43d1e9 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py @@ -165,21 +165,27 @@ async def load_feature_flags( loaded_feature_flags = [] # Needs to be removed unknown keyword argument for list_configuration_settings kwargs.pop("sentinel_keys", None) + endpoint = self._client._impl._config.endpoint # pylint: disable=protected-access filters_used: Dict[str, bool] = {} for select in feature_flag_selectors: feature_flags = self._client.list_configuration_settings( key_filter=FEATURE_FLAG_PREFIX + select.key_filter, label_filter=select.label_filter, **kwargs ) async for feature_flag in feature_flags: - loaded_feature_flags.append(json.loads(feature_flag.value)) if not isinstance(feature_flag, FeatureFlagConfigurationSetting): # If the feature flag is not a FeatureFlagConfigurationSetting, it means it was selected by # mistake, so we should ignore it. continue + feature_flag_value = json.loads(feature_flag.value) + + self._feature_flag_telemetry(endpoint, feature_flag, feature_flag_value) + self._feature_flag_appconfig_telemetry(feature_flag, filters_used) + + loaded_feature_flags.append(feature_flag_value) + if feature_flag_refresh_enabled: feature_flag_sentinel_keys[(feature_flag.key, feature_flag.label)] = feature_flag.etag - self._feature_flag_telemetry(feature_flag, filters_used) return loaded_feature_flags, feature_flag_sentinel_keys, filters_used @distributed_trace diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py index d084d93e6504..17de89c6f4e5 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_azureappconfigurationproviderasync.py @@ -71,7 +71,10 @@ async def load( # pylint: disable=docstring-keyword-should-match-keyword-only refresh_interval: int = 30, on_refresh_success: Optional[Callable] = None, on_refresh_error: Optional[Callable[[Exception], Awaitable[None]]] = None, - **kwargs + feature_flag_enabled: bool = False, + feature_flag_selectors: Optional[List[SettingSelector]] = None, + feature_flag_refresh_enabled: bool = False, + **kwargs, ) -> "AzureAppConfigurationProvider": """ Loads configuration settings from Azure App Configuration into a Python application. @@ -129,7 +132,7 @@ async def load( # pylint: disable=docstring-keyword-should-match-keyword-only feature_flag_enabled: bool = False, feature_flag_selectors: Optional[List[SettingSelector]] = None, feature_flag_refresh_enabled: bool = False, - **kwargs + **kwargs, ) -> "AzureAppConfigurationProvider": """ Loads configuration settings from Azure App Configuration into a Python application. @@ -334,7 +337,7 @@ def __init__(self, **kwargs) -> None: replica_discovery_enabled=kwargs.pop("replica_discovery_enabled", True), min_backoff_sec=min_backoff, max_backoff_sec=max_backoff, - **kwargs + **kwargs, ) self._dict: Dict[str, Any] = {} self._secret_clients: Dict[str, SecretClient] = {} diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/dev_requirements.txt b/sdk/appconfiguration/azure-appconfiguration-provider/dev_requirements.txt index 0d90dc7dbabd..f991ec03941f 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/dev_requirements.txt +++ b/sdk/appconfiguration/azure-appconfiguration-provider/dev_requirements.txt @@ -1,5 +1,5 @@ -e ../../core/azure-core -../../appconfiguration/azure-appconfiguration +-e ../azure-appconfiguration -e ../../identity/azure-identity -e ../../keyvault/azure-keyvault-secrets aiohttp>=3.0 diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_aad_sample.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_aad_sample.py index 59af883f55a8..61c51dc50ef8 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_aad_sample.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_aad_sample.py @@ -8,7 +8,7 @@ from azure.appconfiguration.provider.aio import load from azure.appconfiguration.provider import SettingSelector import os -from sample_utilities import get_authority, get_audience, get_credential, get_client_modifications +from sample_utilities import get_authority, get_credential, get_client_modifications async def main(): diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_key_vault_reference_sample.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_key_vault_reference_sample.py index e81b6975811a..384793ad495e 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_key_vault_reference_sample.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/async_key_vault_reference_sample.py @@ -8,7 +8,7 @@ from azure.appconfiguration.provider.aio import load from azure.appconfiguration.provider import SettingSelector import os -from sample_utilities import get_authority, get_audience, get_credential, get_client_modifications +from sample_utilities import get_authority, get_credential, get_client_modifications async def main(): diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/samples/sample_utilities.py b/sdk/appconfiguration/azure-appconfiguration-provider/samples/sample_utilities.py index 813f2300a196..7e09ee8de8fa 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/samples/sample_utilities.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/samples/sample_utilities.py @@ -13,8 +13,6 @@ - get_credential(): get credential of the ConfigurationClient It is not a file expected to run independently. """ - -import os from azure.identity import AzureAuthorityHosts, DefaultAzureCredential from azure.identity.aio import DefaultAzureCredential as AsyncDefaultAzureCredential diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_feature_management.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_feature_management.py index 8dcb5dbf147b..60833d449212 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_feature_management.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_async_provider_feature_management.py @@ -12,9 +12,6 @@ from test_constants import FEATURE_MANAGEMENT_KEY -from azure.appconfiguration.provider._azureappconfigurationprovider import _delay_failure - - class TestAppConfigurationProviderFeatureManagement(AppConfigTestCase): # method: load @app_config_decorator_async diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_discovery.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_discovery.py index dd47bc47a82d..d14898c205b7 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_discovery.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_discovery.py @@ -12,7 +12,7 @@ _find_origin, find_auto_failover_endpoints, ) -from dns.resolver import NXDOMAIN, YXDOMAIN, LifetimeTimeout, NoNameservers, Answer # cspell:disable-line +from dns.resolver import NXDOMAIN, YXDOMAIN, LifetimeTimeout, NoNameservers # cspell:disable-line AZCONFIG_IO = ".azconfig.io" # cspell:disable-line APPCONFIG_IO = ".appconfig.io" # cspell:disable-line diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_backoff.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_backoff.py index e9bc5c68fb75..fd925b59c502 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_backoff.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_backoff.py @@ -3,8 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for # license information. # -------------------------------------------------------------------------- -from azure.appconfiguration.provider import load, SettingSelector -from devtools_testutils import AzureRecordedTestCase, recorded_by_proxy +from devtools_testutils import recorded_by_proxy from preparers import app_config_decorator from testcase import AppConfigTestCase diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_management.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_management.py index 069fd6076f18..c1ef1e8c7b40 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_management.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_feature_management.py @@ -7,8 +7,7 @@ from azure.appconfiguration import AzureAppConfigurationClient from devtools_testutils import recorded_by_proxy from preparers import app_config_decorator -from testcase import AppConfigTestCase, setup_configs, has_feature_flag -from unittest.mock import patch +from testcase import AppConfigTestCase, setup_configs, has_feature_flag, get_feature_flag from test_constants import FEATURE_MANAGEMENT_KEY @@ -25,6 +24,7 @@ def test_load_only_feature_flags(self, appconfiguration_connection_string): assert len(client.keys()) == 1 assert FEATURE_MANAGEMENT_KEY in client assert has_feature_flag(client, "Alpha") + assert "telemetry" not in get_feature_flag(client, "Alpha") # method: load @recorded_by_proxy diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_refresh.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_refresh.py index a72aecf14243..a60a83d661fa 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_refresh.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/test_provider_refresh.py @@ -5,7 +5,6 @@ # -------------------------------------------------------------------------- import time import unittest -import pytest from unittest.mock import Mock from azure.appconfiguration.provider import WatchKey from devtools_testutils import recorded_by_proxy diff --git a/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py index 563c4070dce5..07f570527ac3 100644 --- a/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py +++ b/sdk/appconfiguration/azure-appconfiguration-provider/tests/testcase.py @@ -195,8 +195,15 @@ def create_feature_flag_config_setting(key, label, enabled): ) -def has_feature_flag(client, feature_id, enabled=False): +def get_feature_flag(client, feature_id): for feature_flag in client[FEATURE_MANAGEMENT_KEY][FEATURE_FLAG_KEY]: if feature_flag["id"] == feature_id: - return feature_flag["enabled"] == enabled + return feature_flag + return None + + +def has_feature_flag(client, feature_id, enabled=False): + feature_flag = get_feature_flag(client, feature_id) + if feature_flag: + return feature_flag["enabled"] == enabled return False