Skip to content

Commit 5a36504

Browse files
feat(user-agent): add custom header User-Agent to AWS SDK requests (aws-powertools#2267)
Co-authored-by: Leandro Damascena <[email protected]>
1 parent 03e64b1 commit 5a36504

File tree

9 files changed

+214
-11
lines changed

9 files changed

+214
-11
lines changed

aws_lambda_powertools/__init__.py

+9-4
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44

55
from pathlib import Path
66

7-
from .logging import Logger
8-
from .metrics import Metrics, single_metric
9-
from .package_logger import set_package_logger_handler
10-
from .tracing import Tracer
7+
from aws_lambda_powertools.logging import Logger
8+
from aws_lambda_powertools.metrics import Metrics, single_metric
9+
from aws_lambda_powertools.package_logger import set_package_logger_handler
10+
from aws_lambda_powertools.shared.user_agent import inject_user_agent
11+
from aws_lambda_powertools.shared.version import VERSION
12+
from aws_lambda_powertools.tracing import Tracer
1113

14+
__version__ = VERSION
1215
__author__ = """Amazon Web Services"""
1316
__all__ = [
1417
"Logger",
@@ -20,3 +23,5 @@
2023
PACKAGE_PATH = Path(__file__).parent
2124

2225
set_package_logger_handler()
26+
27+
inject_user_agent()
+165
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import logging
2+
import os
3+
4+
from aws_lambda_powertools.shared.version import VERSION
5+
6+
powertools_version = VERSION
7+
inject_header = True
8+
9+
try:
10+
import botocore
11+
except ImportError:
12+
# if botocore failed to import, user might be using custom runtime and we can't inject header
13+
inject_header = False
14+
15+
logger = logging.getLogger(__name__)
16+
17+
EXEC_ENV = os.environ.get("AWS_EXECUTION_ENV", "NA")
18+
TARGET_SDK_EVENT = "request-created"
19+
FEATURE_PREFIX = "PT"
20+
DEFAULT_FEATURE = "no-op"
21+
HEADER_NO_OP = f"{FEATURE_PREFIX}/{DEFAULT_FEATURE}/{powertools_version} PTEnv/{EXEC_ENV}"
22+
23+
24+
def _initializer_botocore_session(session):
25+
"""
26+
This function is used to add an extra header for the User-Agent in the Botocore session,
27+
as described in the pull request: https://github.com/boto/botocore/pull/2682
28+
29+
Parameters
30+
----------
31+
session : botocore.session.Session
32+
The Botocore session to which the user-agent function will be registered.
33+
34+
Raises
35+
------
36+
Exception
37+
If there is an issue while adding the extra header for the User-Agent.
38+
39+
"""
40+
try:
41+
session.register(TARGET_SDK_EVENT, _create_feature_function(DEFAULT_FEATURE))
42+
except Exception:
43+
logger.debug("Can't add extra header User-Agent")
44+
45+
46+
def _create_feature_function(feature):
47+
"""
48+
Create and return the `add_powertools_feature` function.
49+
50+
The `add_powertools_feature` function is designed to be registered in boto3's event system.
51+
When registered, it appends the given feature string to the User-Agent header of AWS SDK requests.
52+
53+
Parameters
54+
----------
55+
feature : str
56+
The feature string to be appended to the User-Agent header.
57+
58+
Returns
59+
-------
60+
add_powertools_feature : Callable
61+
The `add_powertools_feature` function that modifies the User-Agent header.
62+
63+
64+
"""
65+
66+
def add_powertools_feature(request, **kwargs):
67+
try:
68+
headers = request.headers
69+
header_user_agent = (
70+
f"{headers['User-Agent']} {FEATURE_PREFIX}/{feature}/{powertools_version} PTEnv/{EXEC_ENV}"
71+
)
72+
73+
# This function is exclusive to client and resources objects created in Powertools
74+
# and must remove the no-op header, if present
75+
if HEADER_NO_OP in headers["User-Agent"] and feature != DEFAULT_FEATURE:
76+
# Remove HEADER_NO_OP + space
77+
header_user_agent = header_user_agent.replace(f"{HEADER_NO_OP} ", "")
78+
79+
headers["User-Agent"] = f"{header_user_agent}"
80+
except Exception:
81+
logger.debug("Can't find User-Agent header")
82+
83+
return add_powertools_feature
84+
85+
86+
# Add feature user-agent to given sdk boto3.session
87+
def register_feature_to_session(session, feature):
88+
"""
89+
Register the given feature string to the event system of the provided boto3 session
90+
and append the feature to the User-Agent header of the request
91+
92+
Parameters
93+
----------
94+
session : boto3.session.Session
95+
The boto3 session to which the feature will be registered.
96+
feature : str
97+
The feature string to be appended to the User-Agent header, e.g., "streaming" in Powertools.
98+
99+
Raises
100+
------
101+
AttributeError
102+
If the provided session does not have an event system.
103+
104+
"""
105+
try:
106+
session.events.register(TARGET_SDK_EVENT, _create_feature_function(feature))
107+
except AttributeError as e:
108+
logger.debug(f"session passed in doesn't have a event system:{e}")
109+
110+
111+
# Add feature user-agent to given sdk boto3.client
112+
def register_feature_to_client(client, feature):
113+
"""
114+
Register the given feature string to the event system of the provided boto3 client
115+
and append the feature to the User-Agent header of the request
116+
117+
Parameters
118+
----------
119+
client : boto3.session.Session.client
120+
The boto3 client to which the feature will be registered.
121+
feature : str
122+
The feature string to be appended to the User-Agent header, e.g., "streaming" in Powertools.
123+
124+
Raises
125+
------
126+
AttributeError
127+
If the provided client does not have an event system.
128+
129+
"""
130+
try:
131+
client.meta.events.register(TARGET_SDK_EVENT, _create_feature_function(feature))
132+
except AttributeError as e:
133+
logger.debug(f"session passed in doesn't have a event system:{e}")
134+
135+
136+
# Add feature user-agent to given sdk boto3.resource
137+
def register_feature_to_resource(resource, feature):
138+
"""
139+
Register the given feature string to the event system of the provided boto3 resource
140+
and append the feature to the User-Agent header of the request
141+
142+
Parameters
143+
----------
144+
resource : boto3.session.Session.resource
145+
The boto3 resource to which the feature will be registered.
146+
feature : str
147+
The feature string to be appended to the User-Agent header, e.g., "streaming" in Powertools.
148+
149+
Raises
150+
------
151+
AttributeError
152+
If the provided resource does not have an event system.
153+
154+
"""
155+
try:
156+
resource.meta.client.meta.events.register(TARGET_SDK_EVENT, _create_feature_function(feature))
157+
except AttributeError as e:
158+
logger.debug(f"resource passed in doesn't have a event system:{e}")
159+
160+
161+
def inject_user_agent():
162+
if inject_header:
163+
# Customize botocore session to inject Powertools header
164+
# See: https://github.com/boto/botocore/pull/2682
165+
botocore.register_initializer(_initializer_botocore_session)
+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""
2+
This file serves to create a constant that informs
3+
the current version of the Powertools package and exposes it in the main module
4+
5+
Since Python 3.8 there the built-in importlib.metadata
6+
When support for Python3.7 is dropped, we can remove the optional importlib_metadata dependency
7+
See: https://docs.python.org/3/library/importlib.metadata.html
8+
"""
9+
import sys
10+
11+
if sys.version_info >= (3, 8):
12+
from importlib.metadata import version
13+
else:
14+
from importlib_metadata import version
15+
16+
VERSION = version("aws-lambda-powertools")

aws_lambda_powertools/utilities/data_classes/code_pipeline_job_event.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import boto3
88

9+
from aws_lambda_powertools.shared import user_agent
910
from aws_lambda_powertools.utilities.data_classes.common import DictWrapper
1011

1112

@@ -203,12 +204,14 @@ def setup_s3_client(self):
203204
BaseClient
204205
An S3 client with the appropriate credentials
205206
"""
206-
return boto3.client(
207+
s3 = boto3.client(
207208
"s3",
208209
aws_access_key_id=self.data.artifact_credentials.access_key_id,
209210
aws_secret_access_key=self.data.artifact_credentials.secret_access_key,
210211
aws_session_token=self.data.artifact_credentials.session_token,
211212
)
213+
user_agent.register_feature_to_client(client=s3, feature="data_classes")
214+
return s3
212215

213216
def find_input_artifact(self, artifact_name: str) -> Optional[CodePipelineArtifact]:
214217
"""Find an input artifact by artifact name

aws_lambda_powertools/utilities/idempotency/persistence/dynamodb.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from botocore.config import Config
1111
from botocore.exceptions import ClientError
1212

13-
from aws_lambda_powertools.shared import constants
13+
from aws_lambda_powertools.shared import constants, user_agent
1414
from aws_lambda_powertools.utilities.idempotency import BasePersistenceLayer
1515
from aws_lambda_powertools.utilities.idempotency.exceptions import (
1616
IdempotencyItemAlreadyExistsError,
@@ -94,6 +94,8 @@ def __init__(
9494
else:
9595
self.client = boto3_client
9696

97+
user_agent.register_feature_to_client(client=self.client, feature="idempotency")
98+
9799
if sort_key_attr == key_attr:
98100
raise ValueError(f"key_attr [{key_attr}] and sort_key_attr [{sort_key_attr}] cannot be the same!")
99101

aws_lambda_powertools/utilities/parameters/base.py

+9-3
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import boto3
2626
from botocore.config import Config
2727

28-
from aws_lambda_powertools.shared import constants
28+
from aws_lambda_powertools.shared import constants, user_agent
2929
from aws_lambda_powertools.shared.functions import resolve_max_age
3030
from aws_lambda_powertools.utilities.parameters.types import TransformOptions
3131

@@ -254,11 +254,14 @@ def _build_boto3_client(
254254
Instance of a boto3 client for Parameters feature (e.g., ssm, appconfig, secretsmanager, etc.)
255255
"""
256256
if client is not None:
257+
user_agent.register_feature_to_client(client=client, feature="parameters")
257258
return client
258259

259260
session = session or boto3.Session()
260261
config = config or Config()
261-
return session.client(service_name=service_name, config=config)
262+
client = session.client(service_name=service_name, config=config)
263+
user_agent.register_feature_to_client(client=client, feature="parameters")
264+
return client
262265

263266
# maintenance: change DynamoDBServiceResource type to ParameterResourceClients when we expand
264267
@staticmethod
@@ -288,11 +291,14 @@ def _build_boto3_resource_client(
288291
Instance of a boto3 resource client for Parameters feature (e.g., dynamodb, etc.)
289292
"""
290293
if client is not None:
294+
user_agent.register_feature_to_resource(resource=client, feature="parameters")
291295
return client
292296

293297
session = session or boto3.Session()
294298
config = config or Config()
295-
return session.resource(service_name=service_name, config=config, endpoint_url=endpoint_url)
299+
client = session.resource(service_name=service_name, config=config, endpoint_url=endpoint_url)
300+
user_agent.register_feature_to_resource(resource=client, feature="parameters")
301+
return client
296302

297303

298304
def get_transform_method(value: str, transform: TransformOptions = None) -> Callable[..., Any]:

aws_lambda_powertools/utilities/streaming/_s3_seekable_io.py

+5
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import boto3
1717

18+
from aws_lambda_powertools.shared import user_agent
1819
from aws_lambda_powertools.utilities.streaming.compat import PowertoolsStreamingBody
1920

2021
if TYPE_CHECKING:
@@ -67,6 +68,7 @@ def __init__(
6768
self._sdk_options = sdk_options
6869
self._sdk_options["Bucket"] = bucket
6970
self._sdk_options["Key"] = key
71+
self._has_user_agent = False
7072
if version_id is not None:
7173
self._sdk_options["VersionId"] = version_id
7274

@@ -77,6 +79,9 @@ def s3_client(self) -> "Client":
7779
"""
7880
if self._s3_client is None:
7981
self._s3_client = boto3.client("s3")
82+
if not self._has_user_agent:
83+
user_agent.register_feature_to_client(client=self._s3_client, feature="streaming")
84+
self._has_user_agent = True
8085
return self._s3_client
8186

8287
@property

mypy.ini

+2-1
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,5 @@ ignore_missing_imports = True
6363
[mypy-ijson]
6464
ignore_missing_imports = True
6565

66-
66+
[mypy-importlib.metadata]
67+
ignore_missing_imports = True

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ aws-xray-sdk = { version = "^2.8.0", optional = true }
3232
fastjsonschema = { version = "^2.14.5", optional = true }
3333
pydantic = { version = "^1.8.2", optional = true }
3434
boto3 = { version = "^1.20.32", optional = true }
35+
importlib-metadata = {version = "^6.6.0", python = "<3.8"}
3536
typing-extensions = "^4.6.2"
3637

3738
[tool.poetry.dev-dependencies]
@@ -86,7 +87,6 @@ mkdocs-material = "^9.1.15"
8687
filelock = "^3.12.0"
8788
checksumdir = "^1.2.0"
8889
mypy-boto3-appconfigdata = "^1.26.70"
89-
importlib-metadata = "^6.6"
9090
ijson = "^3.2.0"
9191
typed-ast = { version = "^1.5.4", python = "< 3.8"}
9292
hvac = "^1.1.0"

0 commit comments

Comments
 (0)