Skip to content
Open
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
140 changes: 140 additions & 0 deletions awsiot/iot_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# 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)
53 changes: 15 additions & 38 deletions awsiot/mqtt5_client_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
**enable_metrics_collection** (`bool`): Controls whether SDK metrics are included in the CONNECT packet.
Defaults to True (metrics enabled).


"""
Expand All @@ -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
Expand All @@ -210,49 +212,19 @@ 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,
use_websockets=False,
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:
Expand Down Expand Up @@ -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 _get(kwargs, 'enable_metrics_collection', True):
client_options.metrics = build_sdk_metrics(certificate_source)

client = awscrt.mqtt5.Client(client_options=client_options)

return client
Expand All @@ -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:
Expand All @@ -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(*,
Expand Down Expand Up @@ -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(*,
Expand All @@ -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(*,
Expand All @@ -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(
Expand Down
Loading
Loading