From 7f321f7f9acf5e2a5634b9bfb0a5cedba8e2e5c4 Mon Sep 17 00:00:00 2001 From: Rakshil Modi Date: Wed, 20 May 2026 13:14:50 -0700 Subject: [PATCH 1/8] Adding feature to metrics --- awsiot/iot_metrics.py | 135 +++++++++++++++++++++++++ awsiot/mqtt5_client_builder.py | 53 +++------- awsiot/mqtt_connection_builder.py | 54 +++------- docsrc/awsiot/iot_metrics.rst | 4 + docsrc/index.rst | 1 + test/test_get_metrics.py | 161 +++++++++++++----------------- 6 files changed, 240 insertions(+), 168 deletions(-) create mode 100644 awsiot/iot_metrics.py create mode 100644 docsrc/awsiot/iot_metrics.rst diff --git a/awsiot/iot_metrics.py b/awsiot/iot_metrics.py new file mode 100644 index 00000000..4a33fe8f --- /dev/null +++ b/awsiot/iot_metrics.py @@ -0,0 +1,135 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0. + +""" +IoT SDK Metrics V2 - SDK layer feature tracking. + +This module implements the SDK-side of the IoT Metrics. +It collects SDK-level feature usage information (such as the certificate source +used for authentication) and packages it into an :class:`~awscrt.aws_iot_metrics.AWSIoTMetrics` +object. The CRT layer then merges these SDK-level features with its own CRT-level +features and embeds the combined metrics string in the MQTT CONNECT packet's +username field. + +Metrics Flow: + 1. A connection builder determines which certificate source is in use. + 2. :func:`build_sdk_metrics` is called with the appropriate + :class:`CertificateSource` value (or ``None`` for connections that + don't use client certificates, e.g. websocket or custom auth). + 3. The returned :class:`~awscrt.aws_iot_metrics.AWSIoTMetrics` object is + passed to the CRT connection/client, which handles final encoding into + the CONNECT username. + +Feature Encoding Format: + SDK features are encoded as comma-separated ``ID/Value`` pairs. + For example, certificate source PKCS11 is encoded as ``I/B``. +""" + +from enum import Enum +from typing import Optional + +from awscrt.aws_iot_metrics import AWSIoTMetrics, IoTMetricsMetadata, IOT_SDK_METRICS_FEATURE_VERSION + +SDK_LIBRARY_NAME = "IoTDeviceSDK/Python" + +class FeatureId(str, Enum): + """SDK-layer feature identifiers. + + Each member maps a feature name to the single-character ID + used in the encoded metrics string. + + Attributes: + CERTIFICATE_SOURCE: Tracks which certificate/authentication method is + used for the connection. Encoded values come from :class:`CertificateSource`. + """ + CERTIFICATE_SOURCE = "I" + +class CertificateSource(str, Enum): + """Certificate source identifiers for metrics feature ``I``. + + Each value corresponds to a specific authentication method used by the + MQTT connection. The single-character value is what gets encoded into the + metrics string sent in the CONNECT packet. + + Attributes: + CERTIFICATE_FILES: Client certificate and private key provided as file paths. + PKCS11: Private key stored in a PKCS#11-compatible hardware security module. + WINDOWS_CERT_STORE: Certificate retrieved from the Windows system certificate store. + PKCS12_FILE: Certificate and private key bundled in a PKCS#12 (.p12/.pfx) file. + """ + CERTIFICATE_FILES = "A" + PKCS11 = "B" + WINDOWS_CERT_STORE = "C" + PKCS12_FILE = "E" + + + +def _get_sdk_version(): + """Return the installed ``awsiotsdk`` package version string. + + Falls back to ``"dev"`` if the package metadata is unavailable (e.g. when + running from a source checkout without installing). + + Returns: + str: A version string like ``"1.21.0"`` or ``"dev"``. + """ + try: + import importlib.metadata + return importlib.metadata.version("awsiotsdk") + except Exception: + return "dev" + + +def _encode_feature_list(certificate_source: Optional[CertificateSource] = None) -> str: + """Encode SDK features into the ``ID/Value,...`` wire format. + + Each feature is represented as its :class:`FeatureId` character followed by + a slash and the feature-specific value character. Multiple features would be + separated by commas (currently only one feature is tracked). + + Args: + certificate_source: The certificate method in use, or ``None`` if no + client certificate is involved. + + Returns: + str: Encoded feature string (e.g. ``"I/A"``), or an empty string if no + features apply. + """ + if certificate_source is not None: + return f"{FeatureId.CERTIFICATE_SOURCE.value}/{certificate_source.value}" + return "" + + +def build_sdk_metrics(certificate_source: Optional[CertificateSource] = None) -> AWSIoTMetrics: + """Build an :class:`~awscrt.aws_iot_metrics.AWSIoTMetrics` instance for the CRT layer. + + This is the main entry point for SDK metrics. Connection builders call this + function to produce the metrics object that the CRT will merge with its own + metrics and embed in the MQTT CONNECT username. + + The returned object always includes: + - ``IoTSDKVersion``: The installed SDK version string. + + When a *certificate_source* is provided, it additionally includes: + - ``IoTSDKFeature``: Encoded feature string (e.g. ``"I/A"``). + - ``IoTSDKMetricsVersion``: The metrics protocol version supported. + + Args: + certificate_source: The certificate/authentication method used by this + connection. Pass ``None`` for connections that don't use client + certificates (e.g. websocket with SigV4, custom authorizers). + + Returns: + AWSIoTMetrics: A metrics object ready to be passed to the CRT + connection or client builder. + """ + metadata = [ + IoTMetricsMetadata(key="IoTSDKVersion", value=_get_sdk_version()), + ] + + feature_list = _encode_feature_list(certificate_source) + if feature_list: + metadata.append(IoTMetricsMetadata(key="IoTSDKFeature", value=feature_list)) + metadata.append(IoTMetricsMetadata(key="IoTSDKMetricsVersion", value=str(IOT_SDK_METRICS_FEATURE_VERSION))) + + return AWSIoTMetrics(library_name=SDK_LIBRARY_NAME, metadata_entries=metadata) diff --git a/awsiot/mqtt5_client_builder.py b/awsiot/mqtt5_client_builder.py index ae9b9750..74d4f833 100644 --- a/awsiot/mqtt5_client_builder.py +++ b/awsiot/mqtt5_client_builder.py @@ -170,8 +170,8 @@ **cipher_pref** (:class:`awscrt.io.TlsCipherPref`): Cipher preference to use for TLS connection. Default is `TlsCipherPref.DEFAULT`. - **enable_metrics_collection** (`bool`): Whether to send the SDK version number in the CONNECT packet. - Default is True. + **disable_metrics** (`bool`): Set to True to disable SDK metrics in the CONNECT packet. + Defaults to False (metrics enabled). """ @@ -184,6 +184,8 @@ import awscrt.mqtt5 import urllib.parse +from awsiot.iot_metrics import CertificateSource, build_sdk_metrics + DEFAULT_WEBSOCKET_MQTT_PORT = 443 DEFAULT_DIRECT_MQTT_PORT = 8883 @@ -210,35 +212,6 @@ def _get(kwargs, name, default=None): return val -_metrics_str = None - - -def _get_metrics_str(current_username=""): - global _metrics_str - - username_has_query = False - if current_username.find("?") != -1: - username_has_query = True - - if _metrics_str is None: - try: - import importlib.metadata - try: - version = importlib.metadata.version("awsiotsdk") - _metrics_str = "SDK=PythonV2&Version={}".format(version) - except importlib.metadata.PackageNotFoundError: - _metrics_str = "SDK=PythonV2&Version=dev" - except BaseException: - _metrics_str = "" - - if not _metrics_str == "": - if username_has_query: - return "&" + _metrics_str - else: - return "?" + _metrics_str - else: - return "" - def _builder( tls_ctx_options, @@ -246,13 +219,12 @@ def _builder( websocket_handshake_transform=None, use_custom_authorizer=False, cipher_pref=awscrt.io.TlsCipherPref.DEFAULT, + certificate_source=None, **kwargs): assert isinstance(cipher_pref, awscrt.io.TlsCipherPref) username = _get(kwargs, 'username', '') - if _get(kwargs, 'enable_metrics_collection', True): - username += _get_metrics_str(username) client_options = _get(kwargs, 'client_options') if client_options is None: @@ -364,6 +336,11 @@ def _builder( tls_ctx = awscrt.io.ClientTlsContext(tls_ctx_options) client_options.tls_ctx = tls_ctx + + # Set SDK metrics for the CRT layer to embed in the CONNECT packet username + if not _get(kwargs, 'disable_metrics', False): + client_options.metrics = build_sdk_metrics(certificate_source) + client = awscrt.mqtt5.Client(client_options=client_options) return client @@ -384,7 +361,7 @@ def mtls_from_path(cert_filepath, pri_key_filepath, **kwargs) -> awscrt.mqtt5.Cl """ _check_required_kwargs(**kwargs) tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls_from_path(cert_filepath, pri_key_filepath) - return _builder(tls_ctx_options, **kwargs) + return _builder(tls_ctx_options, certificate_source=CertificateSource.CERTIFICATE_FILES, **kwargs) def mtls_from_bytes(cert_bytes, pri_key_bytes, **kwargs) -> awscrt.mqtt5.Client: @@ -402,7 +379,7 @@ def mtls_from_bytes(cert_bytes, pri_key_bytes, **kwargs) -> awscrt.mqtt5.Client: """ _check_required_kwargs(**kwargs) tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls(cert_bytes, pri_key_bytes) - return _builder(tls_ctx_options, **kwargs) + return _builder(tls_ctx_options, certificate_source=CertificateSource.CERTIFICATE_FILES, **kwargs) def mtls_with_pkcs11(*, @@ -458,7 +435,7 @@ def mtls_with_pkcs11(*, private_key_label=private_key_label, cert_file_path=cert_filepath, cert_file_contents=cert_bytes) - return _builder(tls_ctx_options, **kwargs) + return _builder(tls_ctx_options, certificate_source=CertificateSource.PKCS11, **kwargs) def mtls_with_pkcs12(*, @@ -484,7 +461,7 @@ def mtls_with_pkcs12(*, tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls_pkcs12( pkcs12_filepath=pkcs12_filepath, pkcs12_password=pkcs12_password) - return _builder(tls_ctx_options, **kwargs) + return _builder(tls_ctx_options, certificate_source=CertificateSource.PKCS12_FILE, **kwargs) def mtls_with_windows_cert_store_path(*, @@ -507,7 +484,7 @@ def mtls_with_windows_cert_store_path(*, _check_required_kwargs(**kwargs) tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls_windows_cert_store_path(cert_store_path) - return _builder(tls_ctx_options, **kwargs) + return _builder(tls_ctx_options, certificate_source=CertificateSource.WINDOWS_CERT_STORE, **kwargs) def websockets_with_default_aws_signing( diff --git a/awsiot/mqtt_connection_builder.py b/awsiot/mqtt_connection_builder.py index 75144563..79455550 100644 --- a/awsiot/mqtt_connection_builder.py +++ b/awsiot/mqtt_connection_builder.py @@ -113,8 +113,8 @@ **cipher_pref** (:class:`awscrt.io.TlsCipherPref`): Cipher preference to use for TLS connection. Default is `TlsCipherPref.DEFAULT`. - **enable_metrics_collection** (`bool`): Whether to send the SDK version number in the CONNECT packet. - Default is True. + **disable_metrics** (`bool`): Set to True to disable SDK metrics in the CONNECT packet. + Default is False (metrics enabled). **http_proxy_options** (:class: 'awscrt.http.HttpProxyOptions'): HTTP proxy options to use """ @@ -127,6 +127,8 @@ import awscrt.mqtt import urllib.parse +from awsiot.iot_metrics import CertificateSource, build_sdk_metrics + def _check_required_kwargs(**kwargs): for required in ['endpoint', 'client_id']: @@ -148,35 +150,6 @@ def _get(kwargs, name, default=None): return val -_metrics_str = None - - -def _get_metrics_str(current_username=""): - global _metrics_str - - username_has_query = False - if current_username.find("?") != -1: - username_has_query = True - - if _metrics_str is None: - try: - import importlib.metadata - try: - version = importlib.metadata.version("awsiotsdk") - _metrics_str = "SDK=PythonV2&Version={}".format(version) - except importlib.metadata.PackageNotFoundError: - _metrics_str = "SDK=PythonV2&Version=dev" - except BaseException: - _metrics_str = "" - - if not _metrics_str == "": - if username_has_query: - return "&" + _metrics_str - else: - return "?" + _metrics_str - else: - return "" - def _builder( tls_ctx_options, @@ -184,6 +157,7 @@ def _builder( websocket_handshake_transform=None, use_custom_authorizer=False, cipher_pref=awscrt.io.TlsCipherPref.DEFAULT, + certificate_source=None, **kwargs): assert isinstance(cipher_pref, awscrt.io.TlsCipherPref) @@ -225,12 +199,15 @@ def _builder( _get(kwargs, 'tcp_keep_alive_max_probes', _get(kwargs, 'tcp_keepalive_max_probes', 0)) username = _get(kwargs, 'username', '') - if _get(kwargs, 'enable_metrics_collection', True): - username += _get_metrics_str(username) if username == "": username = None + # Set SDK metrics for the CRT layer to embed in the CONNECT packet username + metrics = None + if not _get(kwargs, 'disable_metrics', False): + metrics = build_sdk_metrics(certificate_source) + client_bootstrap = _get(kwargs, 'client_bootstrap') if client_bootstrap is None: client_bootstrap = awscrt.io.ClientBootstrap.get_or_create_static_default() @@ -262,6 +239,7 @@ def _builder( on_connection_success=_get(kwargs, 'on_connection_success'), on_connection_failure=_get(kwargs, 'on_connection_failure'), on_connection_closed=_get(kwargs, 'on_connection_closed'), + metrics=metrics, ) @@ -280,7 +258,7 @@ def mtls_from_path(cert_filepath, pri_key_filepath, **kwargs) -> awscrt.mqtt.Con """ _check_required_kwargs(**kwargs) tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls_from_path(cert_filepath, pri_key_filepath) - return _builder(tls_ctx_options, **kwargs) + return _builder(tls_ctx_options, certificate_source=CertificateSource.CERTIFICATE_FILES, **kwargs) def mtls_from_bytes(cert_bytes, pri_key_bytes, **kwargs) -> awscrt.mqtt.Connection: @@ -298,7 +276,7 @@ def mtls_from_bytes(cert_bytes, pri_key_bytes, **kwargs) -> awscrt.mqtt.Connecti """ _check_required_kwargs(**kwargs) tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls(cert_bytes, pri_key_bytes) - return _builder(tls_ctx_options, **kwargs) + return _builder(tls_ctx_options, certificate_source=CertificateSource.CERTIFICATE_FILES, **kwargs) def mtls_with_pkcs11(*, @@ -355,7 +333,7 @@ def mtls_with_pkcs11(*, cert_file_path=cert_filepath, cert_file_contents=cert_bytes) - return _builder(tls_ctx_options, **kwargs) + return _builder(tls_ctx_options, certificate_source=CertificateSource.PKCS11, **kwargs) def mtls_with_pkcs12(*, @@ -381,7 +359,7 @@ def mtls_with_pkcs12(*, tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls_pkcs12( pkcs12_filepath=pkcs12_filepath, pkcs12_password=pkcs12_password) - return _builder(tls_ctx_options, **kwargs) + return _builder(tls_ctx_options, certificate_source=CertificateSource.PKCS12_FILE, **kwargs) def mtls_with_windows_cert_store_path(*, @@ -405,7 +383,7 @@ def mtls_with_windows_cert_store_path(*, tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls_windows_cert_store_path(cert_store_path) - return _builder(tls_ctx_options, **kwargs) + return _builder(tls_ctx_options, certificate_source=CertificateSource.WINDOWS_CERT_STORE, **kwargs) def websockets_with_default_aws_signing( diff --git a/docsrc/awsiot/iot_metrics.rst b/docsrc/awsiot/iot_metrics.rst new file mode 100644 index 00000000..713f849d --- /dev/null +++ b/docsrc/awsiot/iot_metrics.rst @@ -0,0 +1,4 @@ +iot_metrics +============== + +.. automodule:: iot_metrics diff --git a/docsrc/index.rst b/docsrc/index.rst index 68cb82e1..034e4e7d 100644 --- a/docsrc/index.rst +++ b/docsrc/index.rst @@ -28,6 +28,7 @@ API Reference awsiot/iotidentity awsiot/iotjobs awsiot/iotshadow + awsiot/iot_metrics diff --git a/test/test_get_metrics.py b/test/test_get_metrics.py index 0c0d7d8b..1ea323e6 100644 --- a/test/test_get_metrics.py +++ b/test/test_get_metrics.py @@ -2,126 +2,103 @@ # SPDX-License-Identifier: Apache-2.0. import unittest -from unittest.mock import patch +from awsiot.iot_metrics import ( + CertificateSource, + FeatureId, + SDK_LIBRARY_NAME, + _encode_feature_list, + _get_sdk_version, + build_sdk_metrics, +) -class TestImportlibMetadata(unittest.TestCase): - """Test that importlib.metadata is used instead of pkg_resources""" - def setUp(self): - """Reset the metrics string cache before each test""" - # Reset the cached metrics string in both modules - import awsiot.mqtt5_client_builder - import awsiot.mqtt_connection_builder +class TestFeatureEncoding(unittest.TestCase): - # Reset the global _metrics_str variable - awsiot.mqtt_connection_builder._metrics_str = None - awsiot.mqtt5_client_builder._metrics_str = None + def test_certificate_files(self): + self.assertEqual(_encode_feature_list(CertificateSource.CERTIFICATE_FILES), "I/A") - def test_metrics_string_generation_mqtt_connection_builder(self): - """Test that mqtt_connection_builder uses importlib.metadata for version detection""" - from awsiot import mqtt_connection_builder + def test_pkcs11(self): + self.assertEqual(_encode_feature_list(CertificateSource.PKCS11), "I/B") - # Mock importlib.metadata.version to return a known version - with patch("importlib.metadata.version") as mock_version: - mock_version.return_value = "1.2.3" + def test_windows_cert_store(self): + self.assertEqual(_encode_feature_list(CertificateSource.WINDOWS_CERT_STORE), "I/C") - # Call the function that uses version detection - # We need to access the private function for testing - result = mqtt_connection_builder._get_metrics_str("test_username") + def test_pkcs12(self): + self.assertEqual(_encode_feature_list(CertificateSource.PKCS12_FILE), "I/E") - # Verify that importlib.metadata.version was called - mock_version.assert_called_once_with("awsiotsdk") + def test_none_returns_empty(self): + self.assertEqual(_encode_feature_list(None), "") - # Verify the result contains the expected format - self.assertIn("SDK=PythonV2&Version=1.2.3", result) - def test_metrics_string_generation_mqtt5_client_builder(self): - """Test that mqtt5_client_builder uses importlib.metadata for version detection""" - from awsiot import mqtt5_client_builder +class TestGetSdkVersion(unittest.TestCase): - # Mock importlib.metadata.version to return a known version - with patch("importlib.metadata.version") as mock_version: - mock_version.return_value = "1.2.3" + def test_returns_string(self): + version = _get_sdk_version() + self.assertIsInstance(version, str) + self.assertTrue(len(version) > 0) - # Call the function that uses version detection - # We need to access the private function for testing - result = mqtt5_client_builder._get_metrics_str("test_username") - # Verify that importlib.metadata.version was called - mock_version.assert_called_once_with("awsiotsdk") +class TestBuildSdkMetrics(unittest.TestCase): - # Verify the result contains the expected format - self.assertIn("SDK=PythonV2&Version=1.2.3", result) + def test_with_certificate_source(self): + metrics = build_sdk_metrics(CertificateSource.CERTIFICATE_FILES) - def test_package_not_found_handling_mqtt_connection_builder(self): - """Test that PackageNotFoundError is handled correctly in mqtt_connection_builder""" - import importlib.metadata + self.assertEqual(metrics.library_name, SDK_LIBRARY_NAME) + entries = {e.key: e.value for e in metrics.metadata_entries} + self.assertIn("IoTSDKVersion", entries) + self.assertEqual(entries["IoTSDKFeature"], "I/A") + self.assertIn("IoTSDKMetricsVersion", entries) - from awsiot import mqtt_connection_builder + def test_without_certificate_source(self): + metrics = build_sdk_metrics(None) - # Mock importlib.metadata.version to raise PackageNotFoundError - with patch("importlib.metadata.version") as mock_version: - mock_version.side_effect = importlib.metadata.PackageNotFoundError("Package not found") + self.assertEqual(metrics.library_name, SDK_LIBRARY_NAME) + entries = {e.key: e.value for e in metrics.metadata_entries} + self.assertIn("IoTSDKVersion", entries) + self.assertNotIn("IoTSDKFeature", entries) + self.assertNotIn("IoTSDKMetricsVersion", entries) - # Call the function that uses version detection - result = mqtt_connection_builder._get_metrics_str("test_username") + def test_pkcs11_feature(self): + metrics = build_sdk_metrics(CertificateSource.PKCS11) + entries = {e.key: e.value for e in metrics.metadata_entries} + self.assertEqual(entries["IoTSDKFeature"], "I/B") - # Verify that the fallback version is used - self.assertIn("SDK=PythonV2&Version=dev", result) + def test_pkcs12_feature(self): + metrics = build_sdk_metrics(CertificateSource.PKCS12_FILE) + entries = {e.key: e.value for e in metrics.metadata_entries} + self.assertEqual(entries["IoTSDKFeature"], "I/E") - def test_package_not_found_handling_mqtt5_client_builder(self): - """Test that PackageNotFoundError is handled correctly in mqtt5_client_builder""" - import importlib.metadata + def test_windows_cert_store_feature(self): + metrics = build_sdk_metrics(CertificateSource.WINDOWS_CERT_STORE) + entries = {e.key: e.value for e in metrics.metadata_entries} + self.assertEqual(entries["IoTSDKFeature"], "I/C") - from awsiot import mqtt5_client_builder + def test_library_name(self): + metrics = build_sdk_metrics(CertificateSource.CERTIFICATE_FILES) + self.assertEqual(metrics.library_name, "IoTDeviceSDK/Python") - # Mock importlib.metadata.version to raise PackageNotFoundError - with patch("importlib.metadata.version") as mock_version: - mock_version.side_effect = importlib.metadata.PackageNotFoundError("Package not found") + def test_metrics_version_only_set_with_features(self): + metrics_with = build_sdk_metrics(CertificateSource.CERTIFICATE_FILES) + metrics_without = build_sdk_metrics(None) - # Call the function that uses version detection - result = mqtt5_client_builder._get_metrics_str("test_username") + entries_with = {e.key for e in metrics_with.metadata_entries} + entries_without = {e.key for e in metrics_without.metadata_entries} - # Verify that the fallback version is used - self.assertIn("SDK=PythonV2&Version=dev", result) + self.assertIn("IoTSDKMetricsVersion", entries_with) + self.assertNotIn("IoTSDKMetricsVersion", entries_without) - def test_general_exception_handling_mqtt_connection_builder(self): - """Test that general exceptions are handled correctly in mqtt_connection_builder""" - from awsiot import mqtt_connection_builder - # Mock importlib.metadata.version to raise a general exception - with patch("importlib.metadata.version") as mock_version: - mock_version.side_effect = Exception("Some other error") +class TestEnumValues(unittest.TestCase): - # Call the function that uses version detection - result = mqtt_connection_builder._get_metrics_str("test_username") + def test_feature_id(self): + self.assertEqual(FeatureId.CERTIFICATE_SOURCE.value, "I") - # Verify that empty string is returned on general exception - self.assertEqual(result, "") - - def test_general_exception_handling_mqtt5_client_builder(self): - """Test that general exceptions are handled correctly in mqtt5_client_builder""" - from awsiot import mqtt5_client_builder - - # Mock importlib.metadata.version to raise a general exception - with patch("importlib.metadata.version") as mock_version: - mock_version.side_effect = Exception("Some other error") - - # Call the function that uses version detection - result = mqtt5_client_builder._get_metrics_str("test_username") - - # Verify that empty string is returned on general exception - self.assertEqual(result, "") - - def test_no_pkg_resources_import(self): - """Test that pkg_resources is not imported in the modified files""" - import awsiot.mqtt5_client_builder - import awsiot.mqtt_connection_builder - - # Check that pkg_resources is not in the module's globals - self.assertNotIn("pkg_resources", awsiot.mqtt_connection_builder.__dict__) - self.assertNotIn("pkg_resources", awsiot.mqtt5_client_builder.__dict__) + def test_certificate_source_values(self): + self.assertEqual(CertificateSource.CERTIFICATE_FILES.value, "A") + self.assertEqual(CertificateSource.PKCS11.value, "B") + self.assertEqual(CertificateSource.WINDOWS_CERT_STORE.value, "C") + self.assertEqual(CertificateSource.PKCS12_FILE.value, "E") if __name__ == "__main__": From 1d315b0e877ee2d214120ff465c3b9bab88bca6e Mon Sep 17 00:00:00 2001 From: Rakshil Modi Date: Wed, 20 May 2026 13:55:37 -0700 Subject: [PATCH 2/8] Changed to default --- awsiot/iot_metrics.py | 5 +++++ awsiot/mqtt5_client_builder.py | 6 +++--- awsiot/mqtt_connection_builder.py | 6 +++--- docsrc/awsiot/iot_metrics.rst | 4 ++-- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/awsiot/iot_metrics.py b/awsiot/iot_metrics.py index 4a33fe8f..53f183d4 100644 --- a/awsiot/iot_metrics.py +++ b/awsiot/iot_metrics.py @@ -51,6 +51,10 @@ class CertificateSource(str, Enum): MQTT connection. The single-character value is what gets encoded into the metrics string sent in the CONNECT packet. + Note: + Value ``"D"`` (Java KeyStore) is reserved for the Java SDK and is not + applicable to the Python SDK. It is intentionally skipped here. + Attributes: CERTIFICATE_FILES: Client certificate and private key provided as file paths. PKCS11: Private key stored in a PKCS#11-compatible hardware security module. @@ -60,6 +64,7 @@ class CertificateSource(str, Enum): CERTIFICATE_FILES = "A" PKCS11 = "B" WINDOWS_CERT_STORE = "C" + # "D" is Java KeyStore — not applicable to the Python SDK. PKCS12_FILE = "E" diff --git a/awsiot/mqtt5_client_builder.py b/awsiot/mqtt5_client_builder.py index 74d4f833..c601c7dd 100644 --- a/awsiot/mqtt5_client_builder.py +++ b/awsiot/mqtt5_client_builder.py @@ -170,8 +170,8 @@ **cipher_pref** (:class:`awscrt.io.TlsCipherPref`): Cipher preference to use for TLS connection. Default is `TlsCipherPref.DEFAULT`. - **disable_metrics** (`bool`): Set to True to disable SDK metrics in the CONNECT packet. - Defaults to False (metrics enabled). + **enable_metrics_collection** (`bool`): Set to True to enable SDK metrics in the CONNECT packet. + Defaults to True (metrics enabled). """ @@ -338,7 +338,7 @@ def _builder( client_options.tls_ctx = tls_ctx # Set SDK metrics for the CRT layer to embed in the CONNECT packet username - if not _get(kwargs, 'disable_metrics', False): + if _get(kwargs, 'enable_metrics_collection', True): client_options.metrics = build_sdk_metrics(certificate_source) client = awscrt.mqtt5.Client(client_options=client_options) diff --git a/awsiot/mqtt_connection_builder.py b/awsiot/mqtt_connection_builder.py index 79455550..a6d0d413 100644 --- a/awsiot/mqtt_connection_builder.py +++ b/awsiot/mqtt_connection_builder.py @@ -113,8 +113,8 @@ **cipher_pref** (:class:`awscrt.io.TlsCipherPref`): Cipher preference to use for TLS connection. Default is `TlsCipherPref.DEFAULT`. - **disable_metrics** (`bool`): Set to True to disable SDK metrics in the CONNECT packet. - Default is False (metrics enabled). + **enable_metrics_collection** (`bool`): Set to True to enable SDK metrics in the CONNECT packet. + Default is True (metrics enabled). **http_proxy_options** (:class: 'awscrt.http.HttpProxyOptions'): HTTP proxy options to use """ @@ -205,7 +205,7 @@ def _builder( # Set SDK metrics for the CRT layer to embed in the CONNECT packet username metrics = None - if not _get(kwargs, 'disable_metrics', False): + if _get(kwargs, 'enable_metrics_collection', True): metrics = build_sdk_metrics(certificate_source) client_bootstrap = _get(kwargs, 'client_bootstrap') diff --git a/docsrc/awsiot/iot_metrics.rst b/docsrc/awsiot/iot_metrics.rst index 713f849d..2314544f 100644 --- a/docsrc/awsiot/iot_metrics.rst +++ b/docsrc/awsiot/iot_metrics.rst @@ -1,4 +1,4 @@ -iot_metrics +awsiot.iot_metrics ============== -.. automodule:: iot_metrics +.. automodule:: awsiot.iot_metrics From 2f6741edf76b8066a43f10fa6fd969dd1bc241cf Mon Sep 17 00:00:00 2001 From: Rakshil Modi Date: Wed, 20 May 2026 14:09:03 -0700 Subject: [PATCH 3/8] Update doc_string --- awsiot/iot_metrics.py | 6 +++--- awsiot/mqtt5_client_builder.py | 2 +- awsiot/mqtt_connection_builder.py | 2 +- docsrc/awsiot/iot_metrics.rst | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/awsiot/iot_metrics.py b/awsiot/iot_metrics.py index 53f183d4..82cdba35 100644 --- a/awsiot/iot_metrics.py +++ b/awsiot/iot_metrics.py @@ -6,7 +6,7 @@ This module implements the SDK-side of the IoT Metrics. It collects SDK-level feature usage information (such as the certificate source -used for authentication) and packages it into an :class:`~awscrt.aws_iot_metrics.AWSIoTMetrics` +used for authentication) and packages it into an ``AWSIoTMetrics`` object. The CRT layer then merges these SDK-level features with its own CRT-level features and embeds the combined metrics string in the MQTT CONNECT packet's username field. @@ -16,7 +16,7 @@ 2. :func:`build_sdk_metrics` is called with the appropriate :class:`CertificateSource` value (or ``None`` for connections that don't use client certificates, e.g. websocket or custom auth). - 3. The returned :class:`~awscrt.aws_iot_metrics.AWSIoTMetrics` object is + 3. The returned ``AWSIoTMetrics`` object is passed to the CRT connection/client, which handles final encoding into the CONNECT username. @@ -106,7 +106,7 @@ def _encode_feature_list(certificate_source: Optional[CertificateSource] = None) def build_sdk_metrics(certificate_source: Optional[CertificateSource] = None) -> AWSIoTMetrics: - """Build an :class:`~awscrt.aws_iot_metrics.AWSIoTMetrics` instance for the CRT layer. + """Build an ``AWSIoTMetrics`` instance for the CRT layer. This is the main entry point for SDK metrics. Connection builders call this function to produce the metrics object that the CRT will merge with its own diff --git a/awsiot/mqtt5_client_builder.py b/awsiot/mqtt5_client_builder.py index c601c7dd..90c61dbd 100644 --- a/awsiot/mqtt5_client_builder.py +++ b/awsiot/mqtt5_client_builder.py @@ -170,7 +170,7 @@ **cipher_pref** (:class:`awscrt.io.TlsCipherPref`): Cipher preference to use for TLS connection. Default is `TlsCipherPref.DEFAULT`. - **enable_metrics_collection** (`bool`): Set to True to enable SDK metrics in the CONNECT packet. + **enable_metrics_collection** (`bool`): Controls whether SDK metrics are included in the CONNECT packet. Defaults to True (metrics enabled). diff --git a/awsiot/mqtt_connection_builder.py b/awsiot/mqtt_connection_builder.py index a6d0d413..75bfb4ab 100644 --- a/awsiot/mqtt_connection_builder.py +++ b/awsiot/mqtt_connection_builder.py @@ -113,7 +113,7 @@ **cipher_pref** (:class:`awscrt.io.TlsCipherPref`): Cipher preference to use for TLS connection. Default is `TlsCipherPref.DEFAULT`. - **enable_metrics_collection** (`bool`): Set to True to enable SDK metrics in the CONNECT packet. + **enable_metrics_collection** (`bool`): Controls whether SDK metrics are included in the CONNECT packet. Default is True (metrics enabled). **http_proxy_options** (:class: 'awscrt.http.HttpProxyOptions'): HTTP proxy options to use diff --git a/docsrc/awsiot/iot_metrics.rst b/docsrc/awsiot/iot_metrics.rst index 2314544f..917aa63a 100644 --- a/docsrc/awsiot/iot_metrics.rst +++ b/docsrc/awsiot/iot_metrics.rst @@ -1,4 +1,4 @@ awsiot.iot_metrics -============== +===================== .. automodule:: awsiot.iot_metrics From d830671302bfbbaa1a966734cc2b3ece60ee0fb9 Mon Sep 17 00:00:00 2001 From: Rakshil Modi Date: Mon, 15 Jun 2026 16:35:16 -0700 Subject: [PATCH 4/8] adding metrics version --- awsiot/_iot_metrics.py | 72 +++++++++++++++ awsiot/iot_metrics.py | 140 ------------------------------ awsiot/mqtt5_client_builder.py | 23 +++-- awsiot/mqtt_connection_builder.py | 25 +++--- test/test_get_metrics.py | 105 ---------------------- test/test_iot_metrics.py | 46 ++++++++++ 6 files changed, 141 insertions(+), 270 deletions(-) create mode 100644 awsiot/_iot_metrics.py delete mode 100644 awsiot/iot_metrics.py delete mode 100644 test/test_get_metrics.py create mode 100644 test/test_iot_metrics.py diff --git a/awsiot/_iot_metrics.py b/awsiot/_iot_metrics.py new file mode 100644 index 00000000..ae5b0e63 --- /dev/null +++ b/awsiot/_iot_metrics.py @@ -0,0 +1,72 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0. + +""" +Private IoT SDK metrics module. + +Provides SDK-level metadata (version info) to pass to the CRT layer. +The CRT handles all feature detection (certificate source, TLS settings, etc.) +and embeds the combined metrics in the MQTT CONNECT packet username field. + +The metrics version is defined locally as a sanity check — if the CRT bumps +its metrics version, the SDK won't automatically report the new version +without explicit verification that the SDK supports it. +""" + +from awscrt.aws_iot_metrics import AWSIoTMetrics, IoTMetricsMetadata + +_SDK_LIBRARY_NAME = "IoTDeviceSDK/Python" + +# The current version of the IoT SDK metrics format. +# This must match the version expected by CRT layer. +_IOT_SDK_METRICS_VERSION = 1 + + +def _get_sdk_version(): + """ + Return the installed ``awsiotsdk`` package version string. + + Falls back to ``"dev"`` if the package metadata is unavailable (e.g. when + running from a source checkout without installing). + + Returns: + str: A version string like ``1.32.0`` or ``"dev"``. + """ + try: + import importlib.metadata + return importlib.metadata.version("awsiotsdk") + except Exception: + return "dev" + + +def _build_sdk_metrics(): + """ + Build the SDK-level :class:`~awscrt.aws_iot_metrics.AWSIoTMetrics` payload + that is passed down to the CRT layer. + + The returned object carries SDK identity and the metrics format version + via two metadata entries: + + - ``IoTSDKVersion``: the installed ``awsiotsdk`` package version, used + to identify the SDK release on the server side. + - ``IoTSDKMetricsVersion``: the metrics format version this SDK supports. + The CRT only merges SDK-supplied features when this value matches the + version it expects, so bumping :data:`_IOT_SDK_METRICS_VERSION` should + be done in lockstep with CRT changes. + + The CRT layer is responsible for detecting connection-level features + (protocol version, certificate source, TLS settings, proxy type, etc.) + and appending them to the metadata before embedding the result in the + MQTT CONNECT packet username field. + + Returns: + AWSIoTMetrics: A populated metrics object ready to attach to an + MQTT5 client or MQTT3 connection configuration. + """ + return AWSIoTMetrics( + library_name=_SDK_LIBRARY_NAME, + metadata_entries=[ + IoTMetricsMetadata(key="IoTSDKVersion", value=_get_sdk_version()), + IoTMetricsMetadata(key="IoTSDKMetricsVersion", value=str(_IOT_SDK_METRICS_VERSION)), + ] + ) diff --git a/awsiot/iot_metrics.py b/awsiot/iot_metrics.py deleted file mode 100644 index 82cdba35..00000000 --- a/awsiot/iot_metrics.py +++ /dev/null @@ -1,140 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0. - -""" -IoT SDK Metrics V2 - SDK layer feature tracking. - -This module implements the SDK-side of the IoT Metrics. -It collects SDK-level feature usage information (such as the certificate source -used for authentication) and packages it into an ``AWSIoTMetrics`` -object. The CRT layer then merges these SDK-level features with its own CRT-level -features and embeds the combined metrics string in the MQTT CONNECT packet's -username field. - -Metrics Flow: - 1. A connection builder determines which certificate source is in use. - 2. :func:`build_sdk_metrics` is called with the appropriate - :class:`CertificateSource` value (or ``None`` for connections that - don't use client certificates, e.g. websocket or custom auth). - 3. The returned ``AWSIoTMetrics`` object is - passed to the CRT connection/client, which handles final encoding into - the CONNECT username. - -Feature Encoding Format: - SDK features are encoded as comma-separated ``ID/Value`` pairs. - For example, certificate source PKCS11 is encoded as ``I/B``. -""" - -from enum import Enum -from typing import Optional - -from awscrt.aws_iot_metrics import AWSIoTMetrics, IoTMetricsMetadata, IOT_SDK_METRICS_FEATURE_VERSION - -SDK_LIBRARY_NAME = "IoTDeviceSDK/Python" - -class FeatureId(str, Enum): - """SDK-layer feature identifiers. - - Each member maps a feature name to the single-character ID - used in the encoded metrics string. - - Attributes: - CERTIFICATE_SOURCE: Tracks which certificate/authentication method is - used for the connection. Encoded values come from :class:`CertificateSource`. - """ - CERTIFICATE_SOURCE = "I" - -class CertificateSource(str, Enum): - """Certificate source identifiers for metrics feature ``I``. - - Each value corresponds to a specific authentication method used by the - MQTT connection. The single-character value is what gets encoded into the - metrics string sent in the CONNECT packet. - - Note: - Value ``"D"`` (Java KeyStore) is reserved for the Java SDK and is not - applicable to the Python SDK. It is intentionally skipped here. - - Attributes: - CERTIFICATE_FILES: Client certificate and private key provided as file paths. - PKCS11: Private key stored in a PKCS#11-compatible hardware security module. - WINDOWS_CERT_STORE: Certificate retrieved from the Windows system certificate store. - PKCS12_FILE: Certificate and private key bundled in a PKCS#12 (.p12/.pfx) file. - """ - CERTIFICATE_FILES = "A" - PKCS11 = "B" - WINDOWS_CERT_STORE = "C" - # "D" is Java KeyStore — not applicable to the Python SDK. - PKCS12_FILE = "E" - - - -def _get_sdk_version(): - """Return the installed ``awsiotsdk`` package version string. - - Falls back to ``"dev"`` if the package metadata is unavailable (e.g. when - running from a source checkout without installing). - - Returns: - str: A version string like ``"1.21.0"`` or ``"dev"``. - """ - try: - import importlib.metadata - return importlib.metadata.version("awsiotsdk") - except Exception: - return "dev" - - -def _encode_feature_list(certificate_source: Optional[CertificateSource] = None) -> str: - """Encode SDK features into the ``ID/Value,...`` wire format. - - Each feature is represented as its :class:`FeatureId` character followed by - a slash and the feature-specific value character. Multiple features would be - separated by commas (currently only one feature is tracked). - - Args: - certificate_source: The certificate method in use, or ``None`` if no - client certificate is involved. - - Returns: - str: Encoded feature string (e.g. ``"I/A"``), or an empty string if no - features apply. - """ - if certificate_source is not None: - return f"{FeatureId.CERTIFICATE_SOURCE.value}/{certificate_source.value}" - return "" - - -def build_sdk_metrics(certificate_source: Optional[CertificateSource] = None) -> AWSIoTMetrics: - """Build an ``AWSIoTMetrics`` instance for the CRT layer. - - This is the main entry point for SDK metrics. Connection builders call this - function to produce the metrics object that the CRT will merge with its own - metrics and embed in the MQTT CONNECT username. - - The returned object always includes: - - ``IoTSDKVersion``: The installed SDK version string. - - When a *certificate_source* is provided, it additionally includes: - - ``IoTSDKFeature``: Encoded feature string (e.g. ``"I/A"``). - - ``IoTSDKMetricsVersion``: The metrics protocol version supported. - - Args: - certificate_source: The certificate/authentication method used by this - connection. Pass ``None`` for connections that don't use client - certificates (e.g. websocket with SigV4, custom authorizers). - - Returns: - AWSIoTMetrics: A metrics object ready to be passed to the CRT - connection or client builder. - """ - metadata = [ - IoTMetricsMetadata(key="IoTSDKVersion", value=_get_sdk_version()), - ] - - feature_list = _encode_feature_list(certificate_source) - if feature_list: - metadata.append(IoTMetricsMetadata(key="IoTSDKFeature", value=feature_list)) - metadata.append(IoTMetricsMetadata(key="IoTSDKMetricsVersion", value=str(IOT_SDK_METRICS_FEATURE_VERSION))) - - return AWSIoTMetrics(library_name=SDK_LIBRARY_NAME, metadata_entries=metadata) diff --git a/awsiot/mqtt5_client_builder.py b/awsiot/mqtt5_client_builder.py index 90c61dbd..e31c857a 100644 --- a/awsiot/mqtt5_client_builder.py +++ b/awsiot/mqtt5_client_builder.py @@ -170,8 +170,8 @@ **cipher_pref** (:class:`awscrt.io.TlsCipherPref`): Cipher preference to use for TLS connection. Default is `TlsCipherPref.DEFAULT`. - **enable_metrics_collection** (`bool`): Controls whether SDK metrics are included in the CONNECT packet. - Defaults to True (metrics enabled). + **disable_metrics** (`bool`): Disable IoT SDK metrics in the CONNECT packet username field. + Defaults to False (metrics enabled). """ @@ -184,7 +184,7 @@ import awscrt.mqtt5 import urllib.parse -from awsiot.iot_metrics import CertificateSource, build_sdk_metrics +from awsiot._iot_metrics import _build_sdk_metrics DEFAULT_WEBSOCKET_MQTT_PORT = 443 @@ -219,7 +219,6 @@ def _builder( websocket_handshake_transform=None, use_custom_authorizer=False, cipher_pref=awscrt.io.TlsCipherPref.DEFAULT, - certificate_source=None, **kwargs): assert isinstance(cipher_pref, awscrt.io.TlsCipherPref) @@ -338,9 +337,9 @@ def _builder( client_options.tls_ctx = tls_ctx # Set SDK metrics for the CRT layer to embed in the CONNECT packet username - if _get(kwargs, 'enable_metrics_collection', True): - client_options.metrics = build_sdk_metrics(certificate_source) - + disable_metrics = _get(kwargs, 'disable_metrics', False) + client_options.disable_metrics = disable_metrics + client_options.metrics = None if disable_metrics else _build_sdk_metrics() client = awscrt.mqtt5.Client(client_options=client_options) return client @@ -361,7 +360,7 @@ def mtls_from_path(cert_filepath, pri_key_filepath, **kwargs) -> awscrt.mqtt5.Cl """ _check_required_kwargs(**kwargs) tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls_from_path(cert_filepath, pri_key_filepath) - return _builder(tls_ctx_options, certificate_source=CertificateSource.CERTIFICATE_FILES, **kwargs) + return _builder(tls_ctx_options, **kwargs) def mtls_from_bytes(cert_bytes, pri_key_bytes, **kwargs) -> awscrt.mqtt5.Client: @@ -379,7 +378,7 @@ def mtls_from_bytes(cert_bytes, pri_key_bytes, **kwargs) -> awscrt.mqtt5.Client: """ _check_required_kwargs(**kwargs) tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls(cert_bytes, pri_key_bytes) - return _builder(tls_ctx_options, certificate_source=CertificateSource.CERTIFICATE_FILES, **kwargs) + return _builder(tls_ctx_options, **kwargs) def mtls_with_pkcs11(*, @@ -435,7 +434,7 @@ def mtls_with_pkcs11(*, private_key_label=private_key_label, cert_file_path=cert_filepath, cert_file_contents=cert_bytes) - return _builder(tls_ctx_options, certificate_source=CertificateSource.PKCS11, **kwargs) + return _builder(tls_ctx_options, **kwargs) def mtls_with_pkcs12(*, @@ -461,7 +460,7 @@ def mtls_with_pkcs12(*, tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls_pkcs12( pkcs12_filepath=pkcs12_filepath, pkcs12_password=pkcs12_password) - return _builder(tls_ctx_options, certificate_source=CertificateSource.PKCS12_FILE, **kwargs) + return _builder(tls_ctx_options, **kwargs) def mtls_with_windows_cert_store_path(*, @@ -484,7 +483,7 @@ def mtls_with_windows_cert_store_path(*, _check_required_kwargs(**kwargs) tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls_windows_cert_store_path(cert_store_path) - return _builder(tls_ctx_options, certificate_source=CertificateSource.WINDOWS_CERT_STORE, **kwargs) + return _builder(tls_ctx_options, **kwargs) def websockets_with_default_aws_signing( diff --git a/awsiot/mqtt_connection_builder.py b/awsiot/mqtt_connection_builder.py index 75bfb4ab..b3995c82 100644 --- a/awsiot/mqtt_connection_builder.py +++ b/awsiot/mqtt_connection_builder.py @@ -113,8 +113,8 @@ **cipher_pref** (:class:`awscrt.io.TlsCipherPref`): Cipher preference to use for TLS connection. Default is `TlsCipherPref.DEFAULT`. - **enable_metrics_collection** (`bool`): Controls whether SDK metrics are included in the CONNECT packet. - Default is True (metrics enabled). + **disable_metrics** (`bool`): Disable IoT SDK metrics in the CONNECT packet username field. + Default is False (metrics enabled). **http_proxy_options** (:class: 'awscrt.http.HttpProxyOptions'): HTTP proxy options to use """ @@ -127,7 +127,7 @@ import awscrt.mqtt import urllib.parse -from awsiot.iot_metrics import CertificateSource, build_sdk_metrics +from awsiot._iot_metrics import _build_sdk_metrics def _check_required_kwargs(**kwargs): @@ -157,7 +157,6 @@ def _builder( websocket_handshake_transform=None, use_custom_authorizer=False, cipher_pref=awscrt.io.TlsCipherPref.DEFAULT, - certificate_source=None, **kwargs): assert isinstance(cipher_pref, awscrt.io.TlsCipherPref) @@ -204,9 +203,8 @@ def _builder( username = None # Set SDK metrics for the CRT layer to embed in the CONNECT packet username - metrics = None - if _get(kwargs, 'enable_metrics_collection', True): - metrics = build_sdk_metrics(certificate_source) + disable_metrics = _get(kwargs, 'disable_metrics', False) + metrics =None if disable_metrics else _build_sdk_metrics(), client_bootstrap = _get(kwargs, 'client_bootstrap') if client_bootstrap is None: @@ -239,7 +237,8 @@ def _builder( on_connection_success=_get(kwargs, 'on_connection_success'), on_connection_failure=_get(kwargs, 'on_connection_failure'), on_connection_closed=_get(kwargs, 'on_connection_closed'), - metrics=metrics, + disable_metrics=disable_metrics, + metrics=metrics ) @@ -258,7 +257,7 @@ def mtls_from_path(cert_filepath, pri_key_filepath, **kwargs) -> awscrt.mqtt.Con """ _check_required_kwargs(**kwargs) tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls_from_path(cert_filepath, pri_key_filepath) - return _builder(tls_ctx_options, certificate_source=CertificateSource.CERTIFICATE_FILES, **kwargs) + return _builder(tls_ctx_options, **kwargs) def mtls_from_bytes(cert_bytes, pri_key_bytes, **kwargs) -> awscrt.mqtt.Connection: @@ -276,7 +275,7 @@ def mtls_from_bytes(cert_bytes, pri_key_bytes, **kwargs) -> awscrt.mqtt.Connecti """ _check_required_kwargs(**kwargs) tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls(cert_bytes, pri_key_bytes) - return _builder(tls_ctx_options, certificate_source=CertificateSource.CERTIFICATE_FILES, **kwargs) + return _builder(tls_ctx_options, **kwargs) def mtls_with_pkcs11(*, @@ -333,7 +332,7 @@ def mtls_with_pkcs11(*, cert_file_path=cert_filepath, cert_file_contents=cert_bytes) - return _builder(tls_ctx_options, certificate_source=CertificateSource.PKCS11, **kwargs) + return _builder(tls_ctx_options, **kwargs) def mtls_with_pkcs12(*, @@ -359,7 +358,7 @@ def mtls_with_pkcs12(*, tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls_pkcs12( pkcs12_filepath=pkcs12_filepath, pkcs12_password=pkcs12_password) - return _builder(tls_ctx_options, certificate_source=CertificateSource.PKCS12_FILE, **kwargs) + return _builder(tls_ctx_options, **kwargs) def mtls_with_windows_cert_store_path(*, @@ -383,7 +382,7 @@ def mtls_with_windows_cert_store_path(*, tls_ctx_options = awscrt.io.TlsContextOptions.create_client_with_mtls_windows_cert_store_path(cert_store_path) - return _builder(tls_ctx_options, certificate_source=CertificateSource.WINDOWS_CERT_STORE, **kwargs) + return _builder(tls_ctx_options, **kwargs) def websockets_with_default_aws_signing( diff --git a/test/test_get_metrics.py b/test/test_get_metrics.py deleted file mode 100644 index 1ea323e6..00000000 --- a/test/test_get_metrics.py +++ /dev/null @@ -1,105 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0. - -import unittest - -from awsiot.iot_metrics import ( - CertificateSource, - FeatureId, - SDK_LIBRARY_NAME, - _encode_feature_list, - _get_sdk_version, - build_sdk_metrics, -) - - -class TestFeatureEncoding(unittest.TestCase): - - def test_certificate_files(self): - self.assertEqual(_encode_feature_list(CertificateSource.CERTIFICATE_FILES), "I/A") - - def test_pkcs11(self): - self.assertEqual(_encode_feature_list(CertificateSource.PKCS11), "I/B") - - def test_windows_cert_store(self): - self.assertEqual(_encode_feature_list(CertificateSource.WINDOWS_CERT_STORE), "I/C") - - def test_pkcs12(self): - self.assertEqual(_encode_feature_list(CertificateSource.PKCS12_FILE), "I/E") - - def test_none_returns_empty(self): - self.assertEqual(_encode_feature_list(None), "") - - -class TestGetSdkVersion(unittest.TestCase): - - def test_returns_string(self): - version = _get_sdk_version() - self.assertIsInstance(version, str) - self.assertTrue(len(version) > 0) - - -class TestBuildSdkMetrics(unittest.TestCase): - - def test_with_certificate_source(self): - metrics = build_sdk_metrics(CertificateSource.CERTIFICATE_FILES) - - self.assertEqual(metrics.library_name, SDK_LIBRARY_NAME) - entries = {e.key: e.value for e in metrics.metadata_entries} - self.assertIn("IoTSDKVersion", entries) - self.assertEqual(entries["IoTSDKFeature"], "I/A") - self.assertIn("IoTSDKMetricsVersion", entries) - - def test_without_certificate_source(self): - metrics = build_sdk_metrics(None) - - self.assertEqual(metrics.library_name, SDK_LIBRARY_NAME) - entries = {e.key: e.value for e in metrics.metadata_entries} - self.assertIn("IoTSDKVersion", entries) - self.assertNotIn("IoTSDKFeature", entries) - self.assertNotIn("IoTSDKMetricsVersion", entries) - - def test_pkcs11_feature(self): - metrics = build_sdk_metrics(CertificateSource.PKCS11) - entries = {e.key: e.value for e in metrics.metadata_entries} - self.assertEqual(entries["IoTSDKFeature"], "I/B") - - def test_pkcs12_feature(self): - metrics = build_sdk_metrics(CertificateSource.PKCS12_FILE) - entries = {e.key: e.value for e in metrics.metadata_entries} - self.assertEqual(entries["IoTSDKFeature"], "I/E") - - def test_windows_cert_store_feature(self): - metrics = build_sdk_metrics(CertificateSource.WINDOWS_CERT_STORE) - entries = {e.key: e.value for e in metrics.metadata_entries} - self.assertEqual(entries["IoTSDKFeature"], "I/C") - - def test_library_name(self): - metrics = build_sdk_metrics(CertificateSource.CERTIFICATE_FILES) - self.assertEqual(metrics.library_name, "IoTDeviceSDK/Python") - - def test_metrics_version_only_set_with_features(self): - metrics_with = build_sdk_metrics(CertificateSource.CERTIFICATE_FILES) - metrics_without = build_sdk_metrics(None) - - entries_with = {e.key for e in metrics_with.metadata_entries} - entries_without = {e.key for e in metrics_without.metadata_entries} - - self.assertIn("IoTSDKMetricsVersion", entries_with) - self.assertNotIn("IoTSDKMetricsVersion", entries_without) - - -class TestEnumValues(unittest.TestCase): - - def test_feature_id(self): - self.assertEqual(FeatureId.CERTIFICATE_SOURCE.value, "I") - - def test_certificate_source_values(self): - self.assertEqual(CertificateSource.CERTIFICATE_FILES.value, "A") - self.assertEqual(CertificateSource.PKCS11.value, "B") - self.assertEqual(CertificateSource.WINDOWS_CERT_STORE.value, "C") - self.assertEqual(CertificateSource.PKCS12_FILE.value, "E") - - -if __name__ == "__main__": - unittest.main() diff --git a/test/test_iot_metrics.py b/test/test_iot_metrics.py new file mode 100644 index 00000000..21ccea45 --- /dev/null +++ b/test/test_iot_metrics.py @@ -0,0 +1,46 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0. + +import unittest + +from awsiot._iot_metrics import ( + _SDK_LIBRARY_NAME, + _IOT_SDK_METRICS_VERSION, + _get_sdk_version, + _build_sdk_metrics, +) + + +class TestGetSdkVersion(unittest.TestCase): + + def test_returns_string(self): + version = _get_sdk_version() + self.assertIsInstance(version, str) + self.assertTrue(len(version) > 0) + + +class TestBuildSdkMetrics(unittest.TestCase): + + def test_library_name(self): + metrics = _build_sdk_metrics() + self.assertEqual(metrics.library_name, _SDK_LIBRARY_NAME) + + def test_contains_sdk_version(self): + metrics = _build_sdk_metrics() + entries = {e.key: e.value for e in metrics.metadata_entries} + self.assertIn("IoTSDKVersion", entries) + self.assertEqual(entries["IoTSDKVersion"], _get_sdk_version()) + + def test_contains_metrics_version(self): + metrics = _build_sdk_metrics() + entries = {e.key: e.value for e in metrics.metadata_entries} + self.assertIn("IoTSDKMetricsVersion", entries) + self.assertEqual(entries["IoTSDKMetricsVersion"], str(_IOT_SDK_METRICS_VERSION)) + + def test_only_two_metadata_entries(self): + metrics = _build_sdk_metrics() + self.assertEqual(len(metrics.metadata_entries), 2) + + +if __name__ == "__main__": + unittest.main() From da8674b089dfe3a56ed1685c9ff72cc31aca8cac Mon Sep 17 00:00:00 2001 From: Rakshil Modi Date: Mon, 15 Jun 2026 16:43:25 -0700 Subject: [PATCH 5/8] removed from docs --- docsrc/awsiot/iot_metrics.rst | 4 ---- docsrc/index.rst | 1 - 2 files changed, 5 deletions(-) delete mode 100644 docsrc/awsiot/iot_metrics.rst diff --git a/docsrc/awsiot/iot_metrics.rst b/docsrc/awsiot/iot_metrics.rst deleted file mode 100644 index 917aa63a..00000000 --- a/docsrc/awsiot/iot_metrics.rst +++ /dev/null @@ -1,4 +0,0 @@ -awsiot.iot_metrics -===================== - -.. automodule:: awsiot.iot_metrics diff --git a/docsrc/index.rst b/docsrc/index.rst index 034e4e7d..68cb82e1 100644 --- a/docsrc/index.rst +++ b/docsrc/index.rst @@ -28,7 +28,6 @@ API Reference awsiot/iotidentity awsiot/iotjobs awsiot/iotshadow - awsiot/iot_metrics From d6205af369d8a404e1e4e1e18fa33dfe08b54a57 Mon Sep 17 00:00:00 2001 From: Rakshil Modi Date: Mon, 15 Jun 2026 17:15:28 -0700 Subject: [PATCH 6/8] removing trailing comma --- awsiot/mqtt_connection_builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awsiot/mqtt_connection_builder.py b/awsiot/mqtt_connection_builder.py index b3995c82..d09143a6 100644 --- a/awsiot/mqtt_connection_builder.py +++ b/awsiot/mqtt_connection_builder.py @@ -204,7 +204,7 @@ def _builder( # Set SDK metrics for the CRT layer to embed in the CONNECT packet username disable_metrics = _get(kwargs, 'disable_metrics', False) - metrics =None if disable_metrics else _build_sdk_metrics(), + metrics = None if disable_metrics else _build_sdk_metrics() client_bootstrap = _get(kwargs, 'client_bootstrap') if client_bootstrap is None: From 0bfb3b918ef025fa70d303b6db62ee5c18c175d5 Mon Sep 17 00:00:00 2001 From: Rakshil Modi Date: Tue, 16 Jun 2026 10:15:14 -0700 Subject: [PATCH 7/8] adding test cases --- test/test_get_metrics.py | 197 +++++++++++++++++++++++++++++++++++++++ test/test_iot_metrics.py | 46 --------- 2 files changed, 197 insertions(+), 46 deletions(-) create mode 100644 test/test_get_metrics.py delete mode 100644 test/test_iot_metrics.py diff --git a/test/test_get_metrics.py b/test/test_get_metrics.py new file mode 100644 index 00000000..1ff55ab8 --- /dev/null +++ b/test/test_get_metrics.py @@ -0,0 +1,197 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0. + +import os +import unittest +import uuid +import warnings +from unittest.mock import patch + +import boto3 +import botocore.exceptions + +from awsiot._iot_metrics import ( + _SDK_LIBRARY_NAME, + _IOT_SDK_METRICS_VERSION, + _get_sdk_version, + _build_sdk_metrics, +) + +AWS_DEFAULT_REGION = os.environ.get("AWS_DEFAULT_REGION") + + +class Config: + cache = None + + def __init__(self, endpoint): + self.endpoint = endpoint + + @staticmethod + def get(): + """Raises SkipTest if credentials aren't set up correctly""" + if Config.cache: + return Config.cache + + warnings.simplefilter('ignore', ResourceWarning) + + try: + secrets = boto3.client('secretsmanager', region_name=AWS_DEFAULT_REGION) + response = secrets.get_secret_value(SecretId='unit-test/endpoint') + endpoint = response['SecretString'] + Config.cache = Config(endpoint) + except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as ex: + print(ex) + raise unittest.SkipTest("No credentials") + + return Config.cache + + +def create_client_id(): + return 'test-aws-iot-device-sdk-python-v2-unit-test-{0}'.format(uuid.uuid4()) + +class TestGetSdkVersion(unittest.TestCase): + + def test_calls_importlib_metadata(self): + with patch("importlib.metadata.version") as mock_version: + mock_version.return_value = "1.2.3" + result = _get_sdk_version() + mock_version.assert_called_once_with("awsiotsdk") + self.assertEqual(result, "1.2.3") + + def test_fallback_on_package_not_found(self): + import importlib.metadata + with patch("importlib.metadata.version") as mock_version: + mock_version.side_effect = importlib.metadata.PackageNotFoundError("not found") + result = _get_sdk_version() + self.assertEqual(result, "dev") + + def test_fallback_on_general_exception(self): + with patch("importlib.metadata.version") as mock_version: + mock_version.side_effect = Exception("unexpected") + result = _get_sdk_version() + self.assertEqual(result, "dev") + + +class TestBuildSdkMetrics(unittest.TestCase): + + def test_library_name(self): + metrics = _build_sdk_metrics() + self.assertEqual(metrics.library_name, _SDK_LIBRARY_NAME) + + def test_contains_sdk_version(self): + with patch("awsiot._iot_metrics._get_sdk_version", return_value="1.2.3"): + metrics = _build_sdk_metrics() + entries = {e.key: e.value for e in metrics.metadata_entries} + self.assertIn("IoTSDKVersion", entries) + self.assertEqual(entries["IoTSDKVersion"], "1.2.3") + + def test_contains_metrics_version(self): + metrics = _build_sdk_metrics() + entries = {e.key: e.value for e in metrics.metadata_entries} + self.assertIn("IoTSDKMetricsVersion", entries) + self.assertEqual(entries["IoTSDKMetricsVersion"], str(_IOT_SDK_METRICS_VERSION)) + + def test_only_two_metadata_entries(self): + metrics = _build_sdk_metrics() + self.assertEqual(len(metrics.metadata_entries), 2) + + def test_with_dev_fallback_version(self): + with patch("importlib.metadata.version") as mock_version: + mock_version.side_effect = Exception("no package") + metrics = _build_sdk_metrics() + entries = {e.key: e.value for e in metrics.metadata_entries} + self.assertEqual(entries["IoTSDKVersion"], "dev") + + +class TestMqtt3BuilderMetrics(unittest.TestCase): + """Test that mqtt_connection_builder passes disable_metrics correctly.""" + + def test_metrics_enabled_by_default(self): + """When disable_metrics is not set, builder should pass metrics to Connection.""" + config = Config.get() + import awscrt.io + import awscrt.mqtt + from awsiot import mqtt_connection_builder + + with patch("awsiot._iot_metrics._get_sdk_version", return_value="2.0.0"), \ + patch.object(awscrt.mqtt, "Connection") as mock_conn, \ + patch.object(awscrt.mqtt, "Client"): + mqtt_connection_builder._builder( + awscrt.io.TlsContextOptions(), + endpoint=config.endpoint, + client_id=create_client_id(), + ) + + kwargs = mock_conn.call_args.kwargs + self.assertFalse(kwargs["disable_metrics"]) + self.assertIsNotNone(kwargs["metrics"]) + entries = {e.key: e.value for e in kwargs["metrics"].metadata_entries} + self.assertEqual(entries["IoTSDKVersion"], "2.0.0") + self.assertEqual(entries["IoTSDKMetricsVersion"], str(_IOT_SDK_METRICS_VERSION)) + + def test_metrics_disabled(self): + """When disable_metrics=True, builder should pass None metrics.""" + config = Config.get() + import awscrt.io + import awscrt.mqtt + from awsiot import mqtt_connection_builder + + with patch.object(awscrt.mqtt, "Connection") as mock_conn, \ + patch.object(awscrt.mqtt, "Client"): + mqtt_connection_builder._builder( + awscrt.io.TlsContextOptions(), + endpoint=config.endpoint, + client_id=create_client_id(), + disable_metrics=True, + ) + + kwargs = mock_conn.call_args.kwargs + self.assertTrue(kwargs["disable_metrics"]) + self.assertIsNone(kwargs["metrics"]) + + +class TestMqtt5BuilderMetrics(unittest.TestCase): + """Test that mqtt5_client_builder passes disable_metrics correctly.""" + + def test_metrics_enabled_by_default(self): + """When disable_metrics is not set, builder should set metrics on client_options.""" + config = Config.get() + import awscrt.io + import awscrt.mqtt5 + from awsiot import mqtt5_client_builder + + with patch("awsiot._iot_metrics._get_sdk_version", return_value="2.0.0"), \ + patch.object(awscrt.mqtt5, "Client") as mock_client: + mqtt5_client_builder._builder( + awscrt.io.TlsContextOptions(), + endpoint=config.endpoint, + ) + + client_options = mock_client.call_args.kwargs["client_options"] + self.assertFalse(client_options.disable_metrics) + self.assertIsNotNone(client_options.metrics) + entries = {e.key: e.value for e in client_options.metrics.metadata_entries} + self.assertEqual(entries["IoTSDKVersion"], "2.0.0") + self.assertEqual(entries["IoTSDKMetricsVersion"], str(_IOT_SDK_METRICS_VERSION)) + + def test_metrics_disabled(self): + """When disable_metrics=True, builder should set None metrics on client_options.""" + config = Config.get() + import awscrt.io + import awscrt.mqtt5 + from awsiot import mqtt5_client_builder + + with patch.object(awscrt.mqtt5, "Client") as mock_client: + mqtt5_client_builder._builder( + awscrt.io.TlsContextOptions(), + endpoint=config.endpoint, + disable_metrics=True, + ) + + client_options = mock_client.call_args.kwargs["client_options"] + self.assertTrue(client_options.disable_metrics) + self.assertIsNone(client_options.metrics) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_iot_metrics.py b/test/test_iot_metrics.py deleted file mode 100644 index 21ccea45..00000000 --- a/test/test_iot_metrics.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -# SPDX-License-Identifier: Apache-2.0. - -import unittest - -from awsiot._iot_metrics import ( - _SDK_LIBRARY_NAME, - _IOT_SDK_METRICS_VERSION, - _get_sdk_version, - _build_sdk_metrics, -) - - -class TestGetSdkVersion(unittest.TestCase): - - def test_returns_string(self): - version = _get_sdk_version() - self.assertIsInstance(version, str) - self.assertTrue(len(version) > 0) - - -class TestBuildSdkMetrics(unittest.TestCase): - - def test_library_name(self): - metrics = _build_sdk_metrics() - self.assertEqual(metrics.library_name, _SDK_LIBRARY_NAME) - - def test_contains_sdk_version(self): - metrics = _build_sdk_metrics() - entries = {e.key: e.value for e in metrics.metadata_entries} - self.assertIn("IoTSDKVersion", entries) - self.assertEqual(entries["IoTSDKVersion"], _get_sdk_version()) - - def test_contains_metrics_version(self): - metrics = _build_sdk_metrics() - entries = {e.key: e.value for e in metrics.metadata_entries} - self.assertIn("IoTSDKMetricsVersion", entries) - self.assertEqual(entries["IoTSDKMetricsVersion"], str(_IOT_SDK_METRICS_VERSION)) - - def test_only_two_metadata_entries(self): - metrics = _build_sdk_metrics() - self.assertEqual(len(metrics.metadata_entries), 2) - - -if __name__ == "__main__": - unittest.main() From 4660fc55be8ad6113636bb090a14971e67d1fd46 Mon Sep 17 00:00:00 2001 From: Rakshil Modi Date: Tue, 16 Jun 2026 11:51:36 -0700 Subject: [PATCH 8/8] updating doc string --- awsiot/_iot_metrics.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/awsiot/_iot_metrics.py b/awsiot/_iot_metrics.py index ae5b0e63..3e426abb 100644 --- a/awsiot/_iot_metrics.py +++ b/awsiot/_iot_metrics.py @@ -8,9 +8,6 @@ The CRT handles all feature detection (certificate source, TLS settings, etc.) and embeds the combined metrics in the MQTT CONNECT packet username field. -The metrics version is defined locally as a sanity check — if the CRT bumps -its metrics version, the SDK won't automatically report the new version -without explicit verification that the SDK supports it. """ from awscrt.aws_iot_metrics import AWSIoTMetrics, IoTMetricsMetadata