Skip to content

Enable entra id via env var #40237

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Apr 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
([#40004](https://github.com/Azure/azure-sdk-for-python/pull/40004))
- Support `server.address` attributes when converting Azure SDK messaging spans to envelopes
([#40059](https://github.com/Azure/azure-sdk-for-python/pull/40059))
- Update AKS check to use KUBERNETES_SERVICE_HOST
([#39941](https://github.com/Azure/azure-sdk-for-python/pull/39941))
- Enabled Entra ID Credential configuration via env var
([#40237](https://github.com/Azure/azure-sdk-for-python/pull/40237))

### Breaking Changes

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

### Bugs Fixed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ All configuration options can be passed through the constructors of exporters th
* `connection_string`: The connection string used for your Application Insights resource.
* `disable_offline_storage`: Boolean value to determine whether to disable storing failed telemetry records for retry. Defaults to `False`.
* `storage_directory`: Storage directory in which to store retry files. Defaults to `<tempfile.gettempdir()>/Microsoft/AzureMonitor/opentelemetry-python-<your-instrumentation-key>`.
* `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.
* `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.

## Examples

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

<!-- LINKS -->
[aad_env_var_docs]: https://learn.microsoft.com/azure/azure-monitor/app/azure-ad-authentication
<!-- 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 -->
[aad_for_ai_docs]: https://learn.microsoft.com/azure/azure-monitor/app/azure-ad-authentication?tabs=python
[api_docs]: https://azure.github.io/azure-sdk-for-python/monitor.html#azure-monitor-opentelemetry-exporter
[exporter_samples]: https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/monitor/azure-monitor-opentelemetry-exporter/samples
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"APPLICATIONINSIGHTS_OPENTELEMETRY_RESOURCE_METRIC_DISABLED"
)
_APPLICATIONINSIGHTS_METRIC_NAMESPACE_OPT_IN = "APPLICATIONINSIGHTS_METRIC_NAMESPACE_OPT_IN"
_APPLICATIONINSIGHTS_AUTHENTICATION_STRING = "APPLICATIONINSIGHTS_AUTHENTICATION_STRING"

# RPs

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
RedirectPolicy,
RequestIdPolicy,
)
from azure.identity import ManagedIdentityCredential
from azure.monitor.opentelemetry.exporter._generated import AzureMonitorClient
from azure.monitor.opentelemetry.exporter._generated._configuration import AzureMonitorClientConfiguration
from azure.monitor.opentelemetry.exporter._generated.models import (
Expand All @@ -29,6 +30,7 @@
)
from azure.monitor.opentelemetry.exporter._constants import (
_AZURE_MONITOR_DISTRO_VERSION_ARG,
_APPLICATIONINSIGHTS_AUTHENTICATION_STRING,
_INVALID_STATUS_CODES,
_REACHED_INGESTION_STATUS_CODES,
_REDIRECT_STATUS_CODES,
Expand Down Expand Up @@ -85,7 +87,10 @@ def __init__(self, **kwargs: Any) -> None:
parsed_connection_string = ConnectionStringParser(kwargs.get("connection_string"))

self._api_version = kwargs.get("api_version") or _SERVICE_API_LATEST
self._credential = kwargs.get("credential")
if self._is_stats_exporter():
self._credential = None
else:
self._credential = _get_authentication_credential(**kwargs)
self._consecutive_redirects = 0 # To prevent circular redirects
self._disable_offline_storage = kwargs.get("disable_offline_storage", False)
self._endpoint = parsed_connection_string.endpoint
Expand Down Expand Up @@ -433,3 +438,25 @@ def _format_storage_telemetry_item(item: TelemetryItem) -> TelemetryItem:
item.data.base_data = base_type.from_dict(item.data.base_data.additional_properties) # type: ignore
item.data.base_data.additional_properties = None # type: ignore
return item

# mypy: disable-error-code="union-attr"
def _get_authentication_credential(**kwargs: Any) -> Optional[ManagedIdentityCredential]:
if "credential" in kwargs:
return kwargs.get("credential")
try:
if _APPLICATIONINSIGHTS_AUTHENTICATION_STRING in os.environ:
auth_string = os.getenv(_APPLICATIONINSIGHTS_AUTHENTICATION_STRING, "")
kv_pairs = auth_string.split(";")
auth_string_d = dict(s.split("=") for s in kv_pairs)
auth_string_d = {key.lower(): value for key, value in auth_string_d.items()}
if "authorization" in auth_string_d and auth_string_d["authorization"] == "AAD":
if "clientid" in auth_string_d:
credential = ManagedIdentityCredential(client_id=auth_string_d["clientid"])
return credential
credential = ManagedIdentityCredential()
return credential
except ValueError as exc:
logger.error("APPLICATIONINSIGHTS_AUTHENTICATION_STRING, %s, has invalid format: %s", auth_string, exc)
except Exception as e:
logger.error("Failed to get authentication credential and enable AAD: %s", e)
return None
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
python_requires=">=3.8",
install_requires=[
"azure-core<2.0.0,>=1.28.0",
"azure-identity~=1.17",
"fixedint==0.1.6",
"msrest>=0.6.10",
"opentelemetry-api~=1.26",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
_MONITOR_DOMAIN_MAPPING,
_format_storage_telemetry_item,
_get_auth_policy,
_get_authentication_credential,
BaseExporter,
ExportResult,
)
from azure.monitor.opentelemetry.exporter.statsbeat._state import _REQUESTS_MAP
from azure.monitor.opentelemetry.exporter.statsbeat._exporter import _StatsBeatExporter
from azure.monitor.opentelemetry.exporter._constants import (
_DEFAULT_AAD_SCOPE,
_REQ_DURATION_NAME,
Expand Down Expand Up @@ -72,6 +74,8 @@ def clean_folder(folder):
class TestBaseExporter(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Clear environ so the mocks from past tests do not interfere.
os.environ.clear()
os.environ["APPINSIGHTS_INSTRUMENTATIONKEY"] = "1234abcd-5678-4efa-8abc-1234567890ab"
os.environ["APPLICATIONINSIGHTS_STATSBEAT_DISABLED_ALL"] = "true"
cls._base = BaseExporter()
Expand Down Expand Up @@ -937,25 +941,57 @@ def test_transmission_empty(self):
status = self._base._transmit([])
self.assertEqual(status, ExportResult.SUCCESS)

@mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_authentication_credential")
@mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_auth_policy")
def test_exporter_credential(self, mock_add_credential_policy):
def test_exporter_credential(self, mock_add_credential_policy, mock_get_authentication_credential):
TEST_CREDENTIAL = "TEST_CREDENTIAL"
mock_get_authentication_credential.return_value = TEST_CREDENTIAL
base = BaseExporter(credential=TEST_CREDENTIAL, authentication_policy=TEST_AUTH_POLICY)
self.assertEqual(base._credential, TEST_CREDENTIAL)
mock_add_credential_policy.assert_called_once_with(TEST_CREDENTIAL, TEST_AUTH_POLICY, None)

@mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_authentication_credential")
@mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_auth_policy")
def test_exporter_credential_audience(self, mock_add_credential_policy):
def test_exporter_credential_audience(self, mock_add_credential_policy, mock_get_authentication_credential):
test_cs = "AadAudience=test-aad"
TEST_CREDENTIAL = "TEST_CREDENTIAL"
mock_get_authentication_credential.return_value = TEST_CREDENTIAL
# TODO: replace with mock
base = BaseExporter(
connection_string=test_cs,
credential=TEST_CREDENTIAL,
authentication_policy=TEST_AUTH_POLICY,
)
self.assertEqual(base._credential, TEST_CREDENTIAL)
mock_add_credential_policy.assert_called_once_with(TEST_CREDENTIAL, TEST_AUTH_POLICY, "test-aad")
mock_get_authentication_credential.assert_called_once_with(
connection_string=test_cs,
credential=TEST_CREDENTIAL,
authentication_policy=TEST_AUTH_POLICY,
)

@mock.patch.dict("os.environ", {
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "TEST_CREDENTIAL_ENV_VAR"
})
@mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_authentication_credential")
@mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_auth_policy")
def test_credential_env_var_and_arg(self, mock_add_credential_policy, mock_get_authentication_credential):
mock_get_authentication_credential.return_value = "TEST_CREDENTIAL_ENV_VAR"
base = BaseExporter(authentication_policy=TEST_AUTH_POLICY)
self.assertEqual(base._credential, "TEST_CREDENTIAL_ENV_VAR")
mock_add_credential_policy.assert_called_once_with("TEST_CREDENTIAL_ENV_VAR", TEST_AUTH_POLICY, None)
mock_get_authentication_credential.assert_called_once_with(authentication_policy=TEST_AUTH_POLICY)

@mock.patch.dict("os.environ", {
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "TEST_CREDENTIAL_ENV_VAR"
})
@mock.patch("azure.monitor.opentelemetry.exporter.export._base._get_authentication_credential")
def test_statsbeat_no_credential(self, mock_get_authentication_credential):
mock_get_authentication_credential.return_value = "TEST_CREDENTIAL_ENV_VAR"
statsbeat_exporter = _StatsBeatExporter()
self.assertIsNone(statsbeat_exporter._credential)
mock_get_authentication_credential.assert_not_called()

def test_get_auth_policy(self):
class TestCredential:
def get_token(self):
Expand Down Expand Up @@ -988,6 +1024,115 @@ def get_token():
self.assertEqual(result._credential, credential)
self.assertEqual(result._scopes, ("test_audience/.default",))

@mock.patch.dict("os.environ", {
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=AAD"
})
def test_get_authentication_credential_arg(self):
TEST_CREDENTIAL = "TEST_CREDENTIAL"
result = _get_authentication_credential(
credential=TEST_CREDENTIAL,
)
self.assertEqual(result, TEST_CREDENTIAL)

@mock.patch.dict("os.environ", {
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=AAD"
})
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.logger")
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.ManagedIdentityCredential")
def test_get_authentication_credential_system_assigned(self, mock_managed_identity, mock_logger):
MOCK_MANAGED_IDENTITY_CREDENTIAL = "MOCK_MANAGED_IDENTITY_CREDENTIAL"
mock_managed_identity.return_value = MOCK_MANAGED_IDENTITY_CREDENTIAL
result = _get_authentication_credential(
foo="bar"
)
mock_logger.assert_not_called()
self.assertEqual(result, MOCK_MANAGED_IDENTITY_CREDENTIAL)
mock_managed_identity.assert_called_once_with()

@mock.patch.dict("os.environ", {
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=AAD;ClientId=TEST_CLIENT_ID"
})
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.logger")
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.ManagedIdentityCredential")
def test_get_authentication_credential_client_id(self, mock_managed_identity, mock_logger):
MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL = "MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL"
mock_managed_identity.return_value = MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL
result = _get_authentication_credential(
foo="bar"
)
mock_logger.assert_not_called()
self.assertEqual(result, MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL)
mock_managed_identity.assert_called_once_with(client_id="TEST_CLIENT_ID")

@mock.patch.dict("os.environ", {
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=AAD;ClientId=TEST_CLIENT_ID=bar"
})
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.logger")
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.ManagedIdentityCredential")
def test_get_authentication_credential_misformatted(self, mock_managed_identity, mock_logger):
# Even a single misformatted pair means Entra ID auth is skipped.
MOCK_MANAGED_IDENTITY_CREDENTIAL = "MOCK_MANAGED_IDENTITY_CREDENTIAL"
mock_managed_identity.return_value = MOCK_MANAGED_IDENTITY_CREDENTIAL
result = _get_authentication_credential(
foo="bar"
)
mock_logger.error.assert_called_once()
self.assertIsNone(result)
mock_managed_identity.assert_not_called()

@mock.patch.dict("os.environ", {
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "ClientId=TEST_CLIENT_ID"
})
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.ManagedIdentityCredential")
def test_get_authentication_credential_no_auth(self, mock_managed_identity):
MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL = "MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL"
mock_managed_identity.return_value = MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL
result = _get_authentication_credential(
foo="bar"
)
self.assertIsNone(result)
mock_managed_identity.assert_not_called()

@mock.patch.dict("os.environ", {
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=foobar;ClientId=TEST_CLIENT_ID"
})
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.ManagedIdentityCredential")
def test_get_authentication_credential_no_aad(self, mock_managed_identity):
MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL = "MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL"
mock_managed_identity.return_value = MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL
result = _get_authentication_credential(
foo="bar"
)
self.assertIsNone(result)
mock_managed_identity.assert_not_called()

@mock.patch.dict("os.environ", {
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=foobar;ClientId=TEST_CLIENT_ID"
})
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.ManagedIdentityCredential")
def test_get_authentication_credential_no_aad(self, mock_managed_identity):
MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL = "MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL"
mock_managed_identity.return_value = MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL
result = _get_authentication_credential(
foo="bar"
)
self.assertIsNone(result)
mock_managed_identity.assert_not_called()

@mock.patch.dict("os.environ", {
"APPLICATIONINSIGHTS_AUTHENTICATION_STRING": "Authorization=AAD;ClientId=TEST_CLIENT_ID"
})
@mock.patch("azure.monitor.opentelemetry.exporter.export._base.ManagedIdentityCredential")
def test_get_authentication_credential_error(self, mock_managed_identity):
MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL = "MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL"
mock_managed_identity.return_value = MOCK_MANAGED_IDENTITY_CLIENT_ID_CREDENTIAL
mock_managed_identity.side_effect = ValueError("TEST ERROR")
result = _get_authentication_credential(
foo="bar"
)
self.assertIsNone(result)
mock_managed_identity.assert_called_once_with(client_id="TEST_CLIENT_ID")


def validate_telemetry_item(item1, item2):
return (
Expand Down