Repository navigation
Add feature usage tracking to the User-Agent header #882
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
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| { | ||
| "type": "feature", | ||
| "description": "Add User Agent business metrics to the `User-Agent` header (`m/`), tracking the retry mode and endpoint override." | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| """Business metrics (feature ID) tracking for the User-Agent header. | ||
| Features are recorded in the request properties of each call, where they are used. | ||
| When the request is sent, their metric values are added to the ``m/`` section of the | ||
| User-Agent header. | ||
| """ | ||
|
|
||
| import logging | ||
| from collections.abc import Iterable | ||
| from enum import StrEnum, unique | ||
|
|
||
| from smithy_core.interfaces import TypedProperties | ||
| from smithy_core.types import PropertyKey | ||
|
|
||
| __all__ = [ | ||
| "FEATURE_IDS", | ||
| "MAX_ENCODED_FEATURE_IDS_BYTES", | ||
| "AwsFeatureId", | ||
| "encode_feature_ids", | ||
| "register_feature_id", | ||
| ] | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| @unique | ||
| class AwsFeatureId(StrEnum): | ||
| """Features that can be tracked. | ||
| Each member is named after the feature ID, and its value is the | ||
| feature's metric value, which is what is sent in the User-Agent header. Only | ||
| features this SDK tracks are listed. | ||
| """ | ||
|
|
||
| RETRY_MODE_STANDARD = "E" | ||
| RETRY_MODE_ADAPTIVE = "F" | ||
| ENDPOINT_OVERRIDE = "N" | ||
|
|
||
|
|
||
| FEATURE_IDS = PropertyKey(key="feature_ids", value_type=dict[AwsFeatureId, None]) | ||
| """Features recorded for the current call, in the order they were registered.""" | ||
|
|
||
|
|
||
| def register_feature_id(properties: TypedProperties, feature_id: AwsFeatureId) -> None: | ||
| """Record that the current call used a feature. | ||
| Registering a feature that is already recorded has no effect. Features must be | ||
| registered in ``read_after_signing`` or earlier. Features registered later in an | ||
| attempt aren't sent with that attempt. Values that aren't ``AwsFeatureId`` | ||
| members are ignored, so that tracking never fails a request. | ||
| :param properties: The request properties of the current call. | ||
| :param feature_id: The feature that was used. | ||
| """ | ||
| if not isinstance(feature_id, AwsFeatureId): # pyright: ignore[reportUnnecessaryIsInstance] | ||
| logger.debug("Ignoring unknown feature ID: %r", feature_id) | ||
| return | ||
| features = properties.get(FEATURE_IDS) | ||
| if features is None: | ||
| features = {} | ||
| properties[FEATURE_IDS] = features | ||
| features[feature_id] = None | ||
|
|
||
|
|
||
| MAX_ENCODED_FEATURE_IDS_BYTES = 1024 | ||
| """The maximum size of the encoded feature ID list.""" | ||
|
|
||
|
|
||
| def encode_feature_ids(feature_ids: Iterable[AwsFeatureId]) -> str: | ||
| """Encode features as a comma separated list of metric values of at most 1024 bytes. | ||
| Values are kept in the given order. If the list is too long, values are dropped | ||
| from the end so that no value is ever partially included. | ||
| :param feature_ids: The features to encode, without duplicates. | ||
| """ | ||
| encoded = "" | ||
| for feature_id in feature_ids: | ||
| candidate = f"{encoded},{feature_id.value}" if encoded else feature_id.value | ||
| if len(candidate.encode("utf-8")) > MAX_ENCODED_FEATURE_IDS_BYTES: | ||
| break | ||
| encoded = candidate | ||
| return encoded | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,16 +5,40 @@ | |
| from typing import Any | ||
|
|
||
| import smithy_core | ||
| from smithy_core.aio.retries import ( | ||
| RETRY_STRATEGY, | ||
| AsyncAdaptiveRetryStrategy, | ||
| AsyncStandardRetryStrategy, | ||
| ) | ||
|
Comment on lines
+8
to
+12
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we should bump smithy-aws-core's smithy-core dependency to the release that introduces
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We generally should not manually bump our dependency pins since this is handled automatically by our release infra. For something like this that is required by a dependent, a minor version should be triggered by either a breaking or feature changelog entry. Long term we can make this more fine-grain (this is probably more like an enhancement change, but that triggers a patch bump right now. We can probably add a new field in the future to override the bump type since not every feature is a minor bump and not every enhancement is a patch bump). If this is merged before out next release, nothing should be needed since there already pending breaking changes and features for smithy-core. But adding a feature entry might be safer incase this is released after |
||
| from smithy_core.interceptors import Interceptor, RequestContext | ||
| from smithy_core.interfaces import TypedProperties | ||
| from smithy_core.types import PropertyKey | ||
| from smithy_http import Field | ||
| from smithy_http.aio.interfaces import HTTPRequest | ||
| from smithy_http.interceptors.user_agent import USER_AGENT | ||
| from smithy_http.user_agent import RawStringUserAgentComponent, UserAgentComponent | ||
|
|
||
| from .. import __version__ | ||
| from ..business_metrics import ( | ||
| FEATURE_IDS, | ||
| AwsFeatureId, | ||
| encode_feature_ids, | ||
| register_feature_id, | ||
| ) | ||
|
|
||
| __all__ = ["UserAgentInterceptor"] | ||
|
|
||
| _USERAGENT_SDK_NAME = "aws-sdk-python" | ||
|
|
||
| _WRITTEN_USER_AGENT = PropertyKey( | ||
| key="aws_written_user_agent", value_type=tuple[str, str] | ||
| ) | ||
| """ | ||
| The User-Agent this attempt started with (without m/) and the one it wrote (with m/). | ||
| The next attempt uses them, if there is one, so that m/ isn't added twice. | ||
| """ | ||
|
|
||
|
|
||
| class UserAgentInterceptor(Interceptor[Any, Any, Any, Any]): | ||
| """Adds AWS fields to the UserAgent.""" | ||
|
|
@@ -65,6 +89,39 @@ def read_after_serialization(self, context: RequestContext[Any, Any]) -> None: | |
| RawStringUserAgentComponent(self._ua_suffix) | ||
| ) | ||
|
|
||
| self._register_retry_mode(context.properties) | ||
|
|
||
| def modify_before_transmit( | ||
| self, context: RequestContext[Any, HTTPRequest] | ||
| ) -> HTTPRequest: | ||
| # Runs on every attempt, after identity resolution and signing, so features | ||
| # registered up to that point are included. User-Agent isn't signed by AWS | ||
| # auth schemes, so changing it after signing doesn't affect the signature. | ||
| request = context.transport_request | ||
| encoded = encode_feature_ids(context.properties.get(FEATURE_IDS, {})) | ||
| if encoded and "User-Agent" in request.fields: | ||
| current = request.fields["User-Agent"].as_string() | ||
| # In generated clients each attempt gets a fresh User-Agent. The signer | ||
| # copies the headers and the generic interceptor rewrites them. As a | ||
| # fallback, for example with a signer that doesn't copy them, if a retry | ||
| # still has the header written here, start from its value before m/ was | ||
| # added, so m/ is never added twice and nothing else is removed. | ||
| previous = context.properties.get(_WRITTEN_USER_AGENT) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm a bit confused by the usage of |
||
| base = previous[0] if previous and current == previous[1] else current | ||
| written = f"{base} m/{encoded}" | ||
| request.fields.set_field(Field(name="User-Agent", values=[written])) | ||
| context.properties[_WRITTEN_USER_AGENT] = (base, written) | ||
| return request | ||
|
|
||
| def _register_retry_mode(self, properties: TypedProperties) -> None: | ||
| # Adaptive is a subclass of standard, so it's checked first. Other | ||
| # strategies have no feature ID and aren't reported. | ||
| strategy = properties.get(RETRY_STRATEGY) | ||
| if isinstance(strategy, AsyncAdaptiveRetryStrategy): | ||
| register_feature_id(properties, AwsFeatureId.RETRY_MODE_ADAPTIVE) | ||
| elif isinstance(strategy, AsyncStandardRetryStrategy): | ||
| register_feature_id(properties, AwsFeatureId.RETRY_MODE_STANDARD) | ||
|
|
||
| def _build_sdk_metadata(self) -> list[UserAgentComponent]: | ||
| return [ | ||
| UserAgentComponent(_USERAGENT_SDK_NAME, __version__), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| """End-to-end User Agent 2.1 business metrics through a generated AWS client. | ||
|
|
||
| Drives the generated awsJson1.0 client (``codegen-output/aws-json-1-0``) with a stub | ||
| transport that records each request's User-Agent header, and checks the ``m/`` | ||
| section the SDK sends for the features each call uses. | ||
| """ | ||
|
|
||
| import os | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| import pytest | ||
|
|
||
| pytest.importorskip("awsjson10") | ||
|
|
||
| from awsjson10.client import AsyncJSONRPC10Client | ||
| from awsjson10.config import AsyncJSONRPC10Config | ||
| from awsjson10.models import EmptyInputAndEmptyOutputInput | ||
| from smithy_http import tuples_to_fields | ||
| from smithy_http.aio import HTTPResponse | ||
|
|
||
| _JSON_CONTENT_TYPE = ("content-type", "application/x-amz-json-1.0") | ||
|
|
||
|
|
||
| class _RecordingTransport: | ||
| """Returns canned responses and records the User-Agent of every request.""" | ||
|
|
||
| TIMEOUT_EXCEPTIONS = () | ||
|
|
||
| def __init__(self, *, throttle_first: bool = False) -> None: | ||
| self.user_agents: list[str] = [] | ||
| self._throttle_first = throttle_first | ||
|
|
||
| async def send(self, request: Any, *, request_config: Any = None) -> HTTPResponse: | ||
| self.user_agents.append(request.fields["User-Agent"].as_string()) | ||
| if self._throttle_first and len(self.user_agents) == 1: | ||
| return HTTPResponse( | ||
| status=429, | ||
| fields=tuples_to_fields([_JSON_CONTENT_TYPE]), | ||
| body=b'{"__type":"ThrottlingException"}', | ||
| ) | ||
| return HTTPResponse( | ||
| status=200, | ||
| fields=tuples_to_fields([_JSON_CONTENT_TYPE]), | ||
| body=b"{}", | ||
| ) | ||
|
|
||
|
|
||
| def _business_metrics(user_agent: str) -> list[str]: | ||
| return [part for part in user_agent.split() if part.startswith("m/")] | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def _clean_environment(monkeypatch: pytest.MonkeyPatch) -> None: | ||
| # Keep the host's AWS settings from changing what each call reports. | ||
| for name in ("AWS_RETRY_MODE", "AWS_MAX_ATTEMPTS", "AWS_PROFILE"): | ||
| monkeypatch.delenv(name, raising=False) | ||
| # AWS_ENDPOINT_URL and the service-specific AWS_ENDPOINT_URL_<SERVICE> variables. | ||
| for name in list(os.environ): | ||
| if name.startswith("AWS_ENDPOINT_URL"): | ||
| monkeypatch.delenv(name) | ||
|
|
||
|
|
||
| async def _call( | ||
| tmp_path: Path, transport: _RecordingTransport, **config: Any | ||
| ) -> list[str]: | ||
| missing = str(tmp_path / "missing") | ||
| resolved = await AsyncJSONRPC10Config.resolve( | ||
| config_file_path=missing, | ||
| credentials_file_path=missing, | ||
| region="us-west-2", | ||
| transport=transport, | ||
| aws_access_key_id="AKID", | ||
| aws_secret_access_key="secret", | ||
| **config, | ||
| ) | ||
| client = AsyncJSONRPC10Client(config=resolved) | ||
| await client.empty_input_and_empty_output(EmptyInputAndEmptyOutputInput()) | ||
| return transport.user_agents | ||
|
|
||
|
|
||
| async def test_reports_standard_retry_mode_by_default(tmp_path: Path) -> None: | ||
| user_agents = await _call(tmp_path, _RecordingTransport()) | ||
| assert _business_metrics(user_agents[0]) == ["m/E"] | ||
|
|
||
|
|
||
| async def test_reports_adaptive_retry_mode(tmp_path: Path) -> None: | ||
| user_agents = await _call(tmp_path, _RecordingTransport(), retry_mode="adaptive") | ||
| assert _business_metrics(user_agents[0]) == ["m/F"] | ||
|
|
||
|
|
||
| async def test_reports_adaptive_retry_mode_from_environment( | ||
| tmp_path: Path, monkeypatch: pytest.MonkeyPatch | ||
| ) -> None: | ||
| monkeypatch.setenv("AWS_RETRY_MODE", "adaptive") | ||
| user_agents = await _call(tmp_path, _RecordingTransport()) | ||
| assert _business_metrics(user_agents[0]) == ["m/F"] | ||
|
|
||
|
|
||
| async def test_reports_endpoint_override(tmp_path: Path) -> None: | ||
| user_agents = await _call( | ||
| tmp_path, _RecordingTransport(), endpoint_uri="https://localhost:8443" | ||
| ) | ||
| assert _business_metrics(user_agents[0]) == ["m/E,N"] | ||
|
|
||
|
|
||
| async def test_reports_endpoint_override_from_environment( | ||
| tmp_path: Path, monkeypatch: pytest.MonkeyPatch | ||
| ) -> None: | ||
| monkeypatch.setenv("AWS_ENDPOINT_URL_JSON_RPC_10", "https://localhost:8443") | ||
| user_agents = await _call(tmp_path, _RecordingTransport()) | ||
| assert _business_metrics(user_agents[0]) == ["m/E,N"] | ||
|
|
||
|
|
||
| async def test_retried_call_sends_same_business_metrics_once(tmp_path: Path) -> None: | ||
| user_agents = await _call( | ||
| tmp_path, | ||
| _RecordingTransport(throttle_first=True), | ||
| endpoint_uri="https://localhost:8443", | ||
| ) | ||
| assert len(user_agents) == 2 | ||
| assert [_business_metrics(ua) for ua in user_agents] == [["m/E,N"], ["m/E,N"]] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: Why is
.encode("utf-8")needed here?