Skip to content

Commit e72ab70

Browse files
authored
Enable entra id via env var (#40237)
* Enable entra id via env var * Updated docs * Client id param and statsbeat tests * lint * remove prints * method sig * lint * Lower azure-identity req * lint * fix changelog from last pr
1 parent 8f2e930 commit e72ab70

File tree

6 files changed

+184
-6
lines changed

6 files changed

+184
-6
lines changed

sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
([#40004](https://github.com/Azure/azure-sdk-for-python/pull/40004))
99
- Support `server.address` attributes when converting Azure SDK messaging spans to envelopes
1010
([#40059](https://github.com/Azure/azure-sdk-for-python/pull/40059))
11+
- Update AKS check to use KUBERNETES_SERVICE_HOST
12+
([#39941](https://github.com/Azure/azure-sdk-for-python/pull/39941))
13+
- Enabled Entra ID Credential configuration via env var
14+
([#40237](https://github.com/Azure/azure-sdk-for-python/pull/40237))
1115

1216
### Breaking Changes
1317

@@ -23,8 +27,6 @@
2327
([#39886](https://github.com/Azure/azure-sdk-for-python/pull/39886))
2428
- Populate `client_Ip` on `customEvent` telemetry
2529
([#39923](https://github.com/Azure/azure-sdk-for-python/pull/39923))
26-
- Update AKS check to use KUBERNETES_SERVICE_HOST
27-
([#39941](https://github.com/Azure/azure-sdk-for-python/pull/39941))
2830

2931
### Bugs Fixed
3032

sdk/monitor/azure-monitor-opentelemetry-exporter/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ All configuration options can be passed through the constructors of exporters th
137137
* `connection_string`: The connection string used for your Application Insights resource.
138138
* `disable_offline_storage`: Boolean value to determine whether to disable storing failed telemetry records for retry. Defaults to `False`.
139139
* `storage_directory`: Storage directory in which to store retry files. Defaults to `<tempfile.gettempdir()>/Microsoft/AzureMonitor/opentelemetry-python-<your-instrumentation-key>`.
140-
* `credential`: Token credential, such as ManagedIdentityCredential or ClientSecretCredential, used for [Azure Active Directory (AAD) authentication][aad_for_ai_docs]. Defaults to None. See [samples][exporter_samples] for examples.
140+
* `credential`: Token credential, such as ManagedIdentityCredential or ClientSecretCredential, used for [Azure Active Directory (AAD) authentication][aad_for_ai_docs]. Defaults to None. See [samples][exporter_samples] for examples. The credential will be automatically created from the `APPLICATIONINSIGHTS_AUTHENTICATION_STRING` environment variable if not explicitly passed in. See [documentation][aad_env_var_docs] for more.
141141

142142
## Examples
143143

@@ -670,6 +670,8 @@ For more information see the [Code of Conduct FAQ](https://opensource.microsoft.
670670
contact [[email protected]](mailto:[email protected]) with any additional questions or comments.
671671

672672
<!-- LINKS -->
673+
[aad_env_var_docs]: https://learn.microsoft.com/azure/azure-monitor/app/azure-ad-authentication
674+
<!-- TODO: Update with documentation link to be python-specific after Python docs have been updated to be like Java: https://learn.microsoft.com/en-us/azure/azure-monitor/app/azure-ad-authentication?tabs=java#environment-variable-configuration-2 -->
673675
[aad_for_ai_docs]: https://learn.microsoft.com/azure/azure-monitor/app/azure-ad-authentication?tabs=python
674676
[api_docs]: https://azure.github.io/azure-sdk-for-python/monitor.html#azure-monitor-opentelemetry-exporter
675677
[exporter_samples]: https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/monitor/azure-monitor-opentelemetry-exporter/samples

sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"APPLICATIONINSIGHTS_OPENTELEMETRY_RESOURCE_METRIC_DISABLED"
1616
)
1717
_APPLICATIONINSIGHTS_METRIC_NAMESPACE_OPT_IN = "APPLICATIONINSIGHTS_METRIC_NAMESPACE_OPT_IN"
18+
_APPLICATIONINSIGHTS_AUTHENTICATION_STRING = "APPLICATIONINSIGHTS_AUTHENTICATION_STRING"
1819

1920
# RPs
2021

sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/export/_base.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
RedirectPolicy,
1616
RequestIdPolicy,
1717
)
18+
from azure.identity import ManagedIdentityCredential
1819
from azure.monitor.opentelemetry.exporter._generated import AzureMonitorClient
1920
from azure.monitor.opentelemetry.exporter._generated._configuration import AzureMonitorClientConfiguration
2021
from azure.monitor.opentelemetry.exporter._generated.models import (
@@ -29,6 +30,7 @@
2930
)
3031
from azure.monitor.opentelemetry.exporter._constants import (
3132
_AZURE_MONITOR_DISTRO_VERSION_ARG,
33+
_APPLICATIONINSIGHTS_AUTHENTICATION_STRING,
3234
_INVALID_STATUS_CODES,
3335
_REACHED_INGESTION_STATUS_CODES,
3436
_REDIRECT_STATUS_CODES,
@@ -85,7 +87,10 @@ def __init__(self, **kwargs: Any) -> None:
8587
parsed_connection_string = ConnectionStringParser(kwargs.get("connection_string"))
8688

8789
self._api_version = kwargs.get("api_version") or _SERVICE_API_LATEST
88-
self._credential = kwargs.get("credential")
90+
if self._is_stats_exporter():
91+
self._credential = None
92+
else:
93+
self._credential = _get_authentication_credential(**kwargs)
8994
self._consecutive_redirects = 0 # To prevent circular redirects
9095
self._disable_offline_storage = kwargs.get("disable_offline_storage", False)
9196
self._endpoint = parsed_connection_string.endpoint
@@ -433,3 +438,25 @@ def _format_storage_telemetry_item(item: TelemetryItem) -> TelemetryItem:
433438
item.data.base_data = base_type.from_dict(item.data.base_data.additional_properties) # type: ignore
434439
item.data.base_data.additional_properties = None # type: ignore
435440
return item
441+
442+
# mypy: disable-error-code="union-attr"
443+
def _get_authentication_credential(**kwargs: Any) -> Optional[ManagedIdentityCredential]:
444+
if "credential" in kwargs:
445+
return kwargs.get("credential")
446+
try:
447+
if _APPLICATIONINSIGHTS_AUTHENTICATION_STRING in os.environ:
448+
auth_string = os.getenv(_APPLICATIONINSIGHTS_AUTHENTICATION_STRING, "")
449+
kv_pairs = auth_string.split(";")
450+
auth_string_d = dict(s.split("=") for s in kv_pairs)
451+
auth_string_d = {key.lower(): value for key, value in auth_string_d.items()}
452+
if "authorization" in auth_string_d and auth_string_d["authorization"] == "AAD":
453+
if "clientid" in auth_string_d:
454+
credential = ManagedIdentityCredential(client_id=auth_string_d["clientid"])
455+
return credential
456+
credential = ManagedIdentityCredential()
457+
return credential
458+
except ValueError as exc:
459+
logger.error("APPLICATIONINSIGHTS_AUTHENTICATION_STRING, %s, has invalid format: %s", auth_string, exc)
460+
except Exception as e:
461+
logger.error("Failed to get authentication credential and enable AAD: %s", e)
462+
return None

sdk/monitor/azure-monitor-opentelemetry-exporter/setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
python_requires=">=3.8",
8585
install_requires=[
8686
"azure-core<2.0.0,>=1.28.0",
87+
"azure-identity~=1.17",
8788
"fixedint==0.1.6",
8889
"msrest>=0.6.10",
8990
"opentelemetry-api~=1.26",

sdk/monitor/azure-monitor-opentelemetry-exporter/tests/test_base_exporter.py

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
_MONITOR_DOMAIN_MAPPING,
1414
_format_storage_telemetry_item,
1515
_get_auth_policy,
16+
_get_authentication_credential,
1617
BaseExporter,
1718
ExportResult,
1819
)
1920
from azure.monitor.opentelemetry.exporter.statsbeat._state import _REQUESTS_MAP
21+
from azure.monitor.opentelemetry.exporter.statsbeat._exporter import _StatsBeatExporter
2022
from azure.monitor.opentelemetry.exporter._constants import (
2123
_DEFAULT_AAD_SCOPE,
2224
_REQ_DURATION_NAME,
@@ -72,6 +74,8 @@ def clean_folder(folder):
7274
class TestBaseExporter(unittest.TestCase):
7375
@classmethod
7476
def setUpClass(cls):
77+
# Clear environ so the mocks from past tests do not interfere.
78+
os.environ.clear()
7579
os.environ["APPINSIGHTS_INSTRUMENTATIONKEY"] = "1234abcd-5678-4efa-8abc-1234567890ab"
7680
os.environ["APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL"] = "true"
7781
cls._base = BaseExporter()
@@ -937,25 +941,57 @@ def test_transmission_empty(self):
937941
status = self._base._transmit([])
938942
self.assertEqual(status, ExportResult.SUCCESS)
939943

944+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_authentication_credential")
940945
@mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_auth_policy")
941-
def test_exporter_credential(self, mock_add_credential_policy):
946+
def test_exporter_credential(self, mock_add_credential_policy, mock_get_authentication_credential):
942947
TEST_CREDENTIAL = "TEST_CREDENTIAL"
948+
mock_get_authentication_credential.return_value = TEST_CREDENTIAL
943949
base = BaseExporter(credential=TEST_CREDENTIAL, authentication_policy=TEST_AUTH_POLICY)
944950
self.assertEqual(base._credential, TEST_CREDENTIAL)
945951
mock_add_credential_policy.assert_called_once_with(TEST_CREDENTIAL, TEST_AUTH_POLICY, None)
946952

953+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_authentication_credential")
947954
@mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_auth_policy")
948-
def test_exporter_credential_audience(self, mock_add_credential_policy):
955+
def test_exporter_credential_audience(self, mock_add_credential_policy, mock_get_authentication_credential):
949956
test_cs = "AadAudience=test-aad"
950957
TEST_CREDENTIAL = "TEST_CREDENTIAL"
958+
mock_get_authentication_credential.return_value = TEST_CREDENTIAL
959+
# TODO: replace with mock
951960
base = BaseExporter(
952961
connection_string=test_cs,
953962
credential=TEST_CREDENTIAL,
954963
authentication_policy=TEST_AUTH_POLICY,
955964
)
956965
self.assertEqual(base._credential, TEST_CREDENTIAL)
957966
mock_add_credential_policy.assert_called_once_with(TEST_CREDENTIAL, TEST_AUTH_POLICY, "test-aad")
967+
mock_get_authentication_credential.assert_called_once_with(
968+
connection_string=test_cs,
969+
credential=TEST_CREDENTIAL,
970+
authentication_policy=TEST_AUTH_POLICY,
971+
)
958972

973+
@mock.patch.dict("os.environ", {
974+
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "TEST_CREDENTIAL_ENV_VAR"
975+
})
976+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_authentication_credential")
977+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_auth_policy")
978+
def test_credential_env_var_and_arg(self, mock_add_credential_policy, mock_get_authentication_credential):
979+
mock_get_authentication_credential.return_value = "TEST_CREDENTIAL_ENV_VAR"
980+
base = BaseExporter(authentication_policy=TEST_AUTH_POLICY)
981+
self.assertEqual(base._credential, "TEST_CREDENTIAL_ENV_VAR")
982+
mock_add_credential_policy.assert_called_once_with("TEST_CREDENTIAL_ENV_VAR", TEST_AUTH_POLICY, None)
983+
mock_get_authentication_credential.assert_called_once_with(authentication_policy=TEST_AUTH_POLICY)
984+
985+
@mock.patch.dict("os.environ", {
986+
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "TEST_CREDENTIAL_ENV_VAR"
987+
})
988+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_authentication_credential")
989+
def test_statsbeat_no_credential(self, mock_get_authentication_credential):
990+
mock_get_authentication_credential.return_value = "TEST_CREDENTIAL_ENV_VAR"
991+
statsbeat_exporter = _StatsBeatExporter()
992+
self.assertIsNone(statsbeat_exporter._credential)
993+
mock_get_authentication_credential.assert_not_called()
994+
959995
def test_get_auth_policy(self):
960996
class TestCredential:
961997
def get_token(self):
@@ -988,6 +1024,115 @@ def get_token():
9881024
self.assertEqual(result._credential, credential)
9891025
self.assertEqual(result._scopes, ("test_audience/.default",))
9901026

1027+
@mock.patch.dict("os.environ", {
1028+
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=AAD"
1029+
})
1030+
def test_get_authentication_credential_arg(self):
1031+
TEST_CREDENTIAL = "TEST_CREDENTIAL"
1032+
result = _get_authentication_credential(
1033+
credential=TEST_CREDENTIAL,
1034+
)
1035+
self.assertEqual(result, TEST_CREDENTIAL)
1036+
1037+
@mock.patch.dict("os.environ", {
1038+
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=AAD"
1039+
})
1040+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.logger")
1041+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.ManagedIdentityCredential")
1042+
def test_get_authentication_credential_system_assigned(self, mock_managed_identity, mock_logger):
1043+
MOCK_MANAGED_IDENTITY_CREDENTIAL = "MOCK_MANAGED_IDENTITY_CREDENTIAL"
1044+
mock_managed_identity.return_value = MOCK_MANAGED_IDENTITY_CREDENTIAL
1045+
result = _get_authentication_credential(
1046+
foo="bar"
1047+
)
1048+
mock_logger.assert_not_called()
1049+
self.assertEqual(result, MOCK_MANAGED_IDENTITY_CREDENTIAL)
1050+
mock_managed_identity.assert_called_once_with()
1051+
1052+
@mock.patch.dict("os.environ", {
1053+
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=AAD;ClientId=TEST_CLIENT_ID"
1054+
})
1055+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.logger")
1056+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.ManagedIdentityCredential")
1057+
def test_get_authentication_credential_client_id(self, mock_managed_identity, mock_logger):
1058+
MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL = "MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL"
1059+
mock_managed_identity.return_value = MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL
1060+
result = _get_authentication_credential(
1061+
foo="bar"
1062+
)
1063+
mock_logger.assert_not_called()
1064+
self.assertEqual(result, MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL)
1065+
mock_managed_identity.assert_called_once_with(client_id="TEST_CLIENT_ID")
1066+
1067+
@mock.patch.dict("os.environ", {
1068+
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=AAD;ClientId=TEST_CLIENT_ID=bar"
1069+
})
1070+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.logger")
1071+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.ManagedIdentityCredential")
1072+
def test_get_authentication_credential_misformatted(self, mock_managed_identity, mock_logger):
1073+
# Even a single misformatted pair means Entra ID auth is skipped.
1074+
MOCK_MANAGED_IDENTITY_CREDENTIAL = "MOCK_MANAGED_IDENTITY_CREDENTIAL"
1075+
mock_managed_identity.return_value = MOCK_MANAGED_IDENTITY_CREDENTIAL
1076+
result = _get_authentication_credential(
1077+
foo="bar"
1078+
)
1079+
mock_logger.error.assert_called_once()
1080+
self.assertIsNone(result)
1081+
mock_managed_identity.assert_not_called()
1082+
1083+
@mock.patch.dict("os.environ", {
1084+
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "ClientId=TEST_CLIENT_ID"
1085+
})
1086+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.ManagedIdentityCredential")
1087+
def test_get_authentication_credential_no_auth(self, mock_managed_identity):
1088+
MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL = "MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL"
1089+
mock_managed_identity.return_value = MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL
1090+
result = _get_authentication_credential(
1091+
foo="bar"
1092+
)
1093+
self.assertIsNone(result)
1094+
mock_managed_identity.assert_not_called()
1095+
1096+
@mock.patch.dict("os.environ", {
1097+
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=foobar;ClientId=TEST_CLIENT_ID"
1098+
})
1099+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.ManagedIdentityCredential")
1100+
def test_get_authentication_credential_no_aad(self, mock_managed_identity):
1101+
MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL = "MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL"
1102+
mock_managed_identity.return_value = MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL
1103+
result = _get_authentication_credential(
1104+
foo="bar"
1105+
)
1106+
self.assertIsNone(result)
1107+
mock_managed_identity.assert_not_called()
1108+
1109+
@mock.patch.dict("os.environ", {
1110+
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=foobar;ClientId=TEST_CLIENT_ID"
1111+
})
1112+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.ManagedIdentityCredential")
1113+
def test_get_authentication_credential_no_aad(self, mock_managed_identity):
1114+
MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL = "MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL"
1115+
mock_managed_identity.return_value = MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL
1116+
result = _get_authentication_credential(
1117+
foo="bar"
1118+
)
1119+
self.assertIsNone(result)
1120+
mock_managed_identity.assert_not_called()
1121+
1122+
@mock.patch.dict("os.environ", {
1123+
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=AAD;ClientId=TEST_CLIENT_ID"
1124+
})
1125+
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.ManagedIdentityCredential")
1126+
def test_get_authentication_credential_error(self, mock_managed_identity):
1127+
MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL = "MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL"
1128+
mock_managed_identity.return_value = MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL
1129+
mock_managed_identity.side_effect = ValueError("TEST ERROR")
1130+
result = _get_authentication_credential(
1131+
foo="bar"
1132+
)
1133+
self.assertIsNone(result)
1134+
mock_managed_identity.assert_called_once_with(client_id="TEST_CLIENT_ID")
1135+
9911136

9921137
def validate_telemetry_item(item1, item2):
9931138
return (

0 commit comments

Comments
 (0)