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
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
Expand Up @@ -7,6 +7,7 @@
from smithy_core.endpoints import Endpoint, EndpointResolverParams, resolve_static_uri
from smithy_core.exceptions import EndpointResolutionError

from ...business_metrics import AwsFeatureId, register_feature_id
from ...endpoints import REGIONAL_ENDPOINT_CONFIG

__all__ = ["AsyncStandardRegionalEndpointsResolver"]
Expand All @@ -20,6 +21,7 @@ def __init__(self, endpoint_prefix: str = "bedrock-runtime"):

async def resolve_endpoint(self, params: EndpointResolverParams[Any]) -> Endpoint:
if (static_uri := resolve_static_uri(params)) is not None:
register_feature_id(params.context, AwsFeatureId.ENDPOINT_OVERRIDE)
return Endpoint(uri=static_uri)

region_config = params.context.get(REGIONAL_ENDPOINT_CONFIG)
Expand Down
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:

Copy link
Copy Markdown
Contributor

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?

break
encoded = candidate
return encoded
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 RETRY_STRATEGY. It still allows 0.9.x, so a valid installation can fail while importing this module

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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."""
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused by the usage of _WRITTEN_USER_AGENT. Can you explain what case it's protecting against?

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__),
Expand Down
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"]]
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from smithy_aws_core.aio.endpoints.standard_regional import (
AsyncStandardRegionalEndpointsResolver,
)
from smithy_aws_core.business_metrics import FEATURE_IDS, AwsFeatureId
from smithy_aws_core.endpoints import REGIONAL_ENDPOINT_CONFIG
from smithy_core import URI
from smithy_core.endpoints import EndpointResolverParams
Expand Down Expand Up @@ -94,3 +95,21 @@ async def test_resolve_endpoint_with_sdk_endpoint_and_region():
endpoint = await resolver.resolve_endpoint(params)

assert endpoint.uri.host == "example.com"


async def test_resolve_endpoint_registers_endpoint_override():
resolver = AsyncStandardRegionalEndpointsResolver(endpoint_prefix="service")
params = EndpointConfig.params("https://example.com")

await resolver.resolve_endpoint(params)

assert AwsFeatureId.ENDPOINT_OVERRIDE in params.context.get(FEATURE_IDS, {})


async def test_resolve_regional_endpoint_does_not_register_endpoint_override():
resolver = AsyncStandardRegionalEndpointsResolver(endpoint_prefix="service")
params = EndpointConfig.params(region="us-west-2")

await resolver.resolve_endpoint(params)

assert AwsFeatureId.ENDPOINT_OVERRIDE not in params.context.get(FEATURE_IDS, {})
Loading
Loading