Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
abc57bc
fix(boto3): Inject trace headers before SigV4 signing
pabloDeputter Aug 5, 2026
78f1ee6
fix(stdlib): Preserve signed headers during trace propagation
pabloDeputter Aug 5, 2026
88fdd7d
test(boto3): add tests covering trace propagation + SigV4 signing
pabloDeputter Aug 6, 2026
a1e5f9c
test(boto3): inline span_streaming logic
pabloDeputter Aug 6, 2026
4ba8901
test(stdlib): cover httplib SigV4 trace header preservation
pabloDeputter Aug 6, 2026
92672ca
test(boto3): Add `server_close()` in test cleanup
pabloDeputter Aug 6, 2026
37fa9ff
fix(stdlib): clarify comment
pabloDeputter Aug 6, 2026
e675673
fix(stdlib): Move trace header generation into `endheaders()`
pabloDeputter Aug 6, 2026
14bad29
fix(stdlib): Replace `return set()` with `continue` to avoid skipping…
pabloDeputter Aug 6, 2026
90a77d6
fix(stdlib): Move `get_aws_sigv4_signed_headers` to `sentry_sdk.utils…
pabloDeputter Aug 11, 2026
f2d0cee
fix(stdlib): Make mypy happy
pabloDeputter Aug 11, 2026
e6e9e5e
fix(stdlib): Capture internal exceptions in `endheaders()`
pabloDeputter Aug 11, 2026
3f4dacc
test(utils): Add test for `get_aws_sigv4_signed_headers`
pabloDeputter Aug 11, 2026
6d7301c
fix(aiohttp): Preserve SigV4 signed headers
pabloDeputter Aug 11, 2026
a0c1ad2
test(aiohttp): Add tests related to SigV4 issues
pabloDeputter Aug 11, 2026
e40e931
test(boto3): Add signed header assertions to tests
pabloDeputter Aug 11, 2026
af905db
test(httplib): Add tests for SigV4 signed headers
pabloDeputter Aug 11, 2026
09f8c44
fix(aiohttp): Do `request.rel_url` instead of `request.url` so py3.7-…
pabloDeputter Aug 11, 2026
e07978e
fix(aiohttp): Use `raw_path` instead of `rel_url` to avoid encoding i…
pabloDeputter Aug 11, 2026
08dc4d2
ref(boto3): Clarify logic in `replace_header()`
pabloDeputter Aug 12, 2026
079cf17
ref(utils): Remove bytes parsing from `get_aws_sigv4_signed_headers()…
pabloDeputter Aug 12, 2026
ebcce02
ref(utils): `get_aws_sigv4_signed_headers()` now takes in `authorizat…
pabloDeputter Aug 12, 2026
484a941
chore(stdlib): Track headers during request construction
pabloDeputter Aug 12, 2026
35ff7b4
linting
pabloDeputter Aug 12, 2026
355be07
fix(stdlib): Fix renaming issue
pabloDeputter Aug 12, 2026
5ab6ccb
ref(utils): Remove unused `Mapping` import
pabloDeputter Aug 12, 2026
f04bd51
ref(stdlib): Fix another typo
pabloDeputter Aug 12, 2026
28e33cc
revert aiohttp changes
alexander-alderman-webb Aug 24, 2026
6ff70c4
limit changes to botocore connection classes
alexander-alderman-webb Aug 24, 2026
8003db0
merge master
alexander-alderman-webb Aug 24, 2026
b68e9f7
formatting
alexander-alderman-webb Aug 24, 2026
3ce7587
make mypy happy
alexander-alderman-webb Aug 24, 2026
628543e
clean up trace_url on endheaders
alexander-alderman-webb Aug 24, 2026
a739e84
(ref): Make `get_aws_sigv4_signed_headers` private; renamed to `_get_…
pabloDeputter Aug 24, 2026
3d44029
(tests): Move tests using `AWSHTTPConnection` to `test_aws_http_conne…
pabloDeputter Aug 24, 2026
c706b7b
remove tracking of uncessary headers
pabloDeputter Aug 24, 2026
1ee3b58
ruff fix
pabloDeputter Aug 24, 2026
8762b88
resolve PR comments
pabloDeputter Aug 25, 2026
98195e9
pr comments: made logic a bit easier and more clariyfing comments
pabloDeputter Aug 27, 2026
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
65 changes: 63 additions & 2 deletions sentry_sdk/integrations/boto3.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@
from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.traces import StreamedSpan
from sentry_sdk.tracing import Span
from sentry_sdk.tracing_utils import add_http_breadcrumb, has_span_streaming_enabled
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, Span
from sentry_sdk.tracing_utils import (
add_http_breadcrumb,
add_sentry_baggage_to_headers,
has_span_streaming_enabled,
should_propagate_trace,
)
from sentry_sdk.utils import (
capture_internal_exceptions,
parse_url,
Expand Down Expand Up @@ -49,6 +54,8 @@ def sentry_patched_init(
"request-created",
partial(_sentry_request_created, service_id=service_id),
)
# run after other `before-sign` handlers, allowing it to see and preserve existing baggage.
meta.events.register_last("before-sign", _sentry_before_sign)
meta.events.register("after-call", _sentry_after_call)
meta.events.register("after-call-error", _sentry_after_call_error)

Expand Down Expand Up @@ -143,6 +150,60 @@ def _sentry_request_created(
request.context["_sentrysdk_span"] = span


def _sentry_before_sign(
request: "AWSRequest", signature_version: "Any", **kwargs: "Any"
) -> None:
client = sentry_sdk.get_client()
if client.get_integration(Boto3Integration) is None:
return

with capture_internal_exceptions():
# presigned requests are executed later by another caller. Adding propagation
# headers here would make those headers part of the signature, requiring the caller to reproduce the same values.
if isinstance(signature_version, str) and signature_version.endswith(
("-query", "-presign-post")
):
return

if request.url is None or not should_propagate_trace(client, request.url):
return

def _replace_header(request: "AWSRequest", key: str, value: str) -> None:
"""
Botocore's `HTTPHeaders` inherits from `email.message.Message`, where:
headers["foo"] = "old"
headers["foo"] = "new"
produces two fields: {"foo": "old", "foo": "new"}. So delete existing
fields before assigning replacement.
"""
if key in request.headers:
del request.headers[key]
request.headers[key] = value
Comment thread
alexander-alderman-webb marked this conversation as resolved.

# use span associated with this botocore request
span = request.context.get("_sentrysdk_span")

headers = sentry_sdk.get_current_scope().iter_trace_propagation_headers(
span=span
)
Comment thread
alexander-alderman-webb marked this conversation as resolved.
for header_name, header_value in headers:
if header_name != BAGGAGE_HEADER_NAME:
# normal headers (e.g. `sentry-trace`) are non-shared, so replace stale values
_replace_header(request, header_name, header_value)
continue

# merge existing `baggage` values under single header
existing_values = request.headers.get_all(BAGGAGE_HEADER_NAME, [])
combined_baggage = {
BAGGAGE_HEADER_NAME: ",".join(str(value) for value in existing_values)
}
# preserve third-party baggage, replace stale `sentry-*` values
add_sentry_baggage_to_headers(combined_baggage, header_value)
_replace_header(
request, BAGGAGE_HEADER_NAME, combined_baggage[BAGGAGE_HEADER_NAME]
)


def _sentry_after_call(
context: "Dict[str, Any]", parsed: "Dict[str, Any]", **kwargs: "Any"
) -> None:
Expand Down
183 changes: 180 additions & 3 deletions sentry_sdk/integrations/stdlib.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from sentry_sdk.integrations import Integration
from sentry_sdk.scope import add_global_event_processor, should_send_default_pii
from sentry_sdk.traces import StreamedSpan
from sentry_sdk.tracing import Span
from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, Span
from sentry_sdk.tracing_utils import (
EnvironHeaders,
add_http_breadcrumb,
Expand All @@ -20,6 +20,8 @@
)
from sentry_sdk.utils import (
SENSITIVE_DATA_SUBSTITUTE,
_get_aws_sigv4_signed_headers_from_authorization_header,
_get_aws_sigv4_signed_headers_from_url_query_string,
capture_internal_exceptions,
ensure_integration_enabled,
is_sentry_url,
Expand All @@ -29,7 +31,7 @@
)

if TYPE_CHECKING:
from typing import Any, Callable, Dict, List, Optional, Union
from typing import Any, Callable, Dict, List, Optional, Set, Union

from sentry_sdk._types import Event, Hint

Expand All @@ -40,6 +42,14 @@
"build": sys.version,
}

_SENTRY_HEADER_NAMES = frozenset((BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME))

try:
from botocore.awsrequest import AWSHTTPConnection, AWSHTTPSConnection
except ImportError:
AWSHTTPConnection = None # type: ignore[misc,assignment]
AWSHTTPSConnection = None # type: ignore[misc,assignment]


class StdlibIntegration(Integration):
identifier = "stdlib"
Expand Down Expand Up @@ -73,6 +83,164 @@ def _complete_span(span: "Union[Span, StreamedSpan]") -> None:
add_http_request_source(span)


def _get_wrapped_putheader(
original_putheader: "Callable[..., Any]",
) -> "Callable[..., Any]":
"""Record existing `sentry-trace` and SigV4-signed propagation headers to skip."""

def putheader(self: "HTTPConnection", header: "Any", *values: "Any") -> "Any":
rv = original_putheader(self, header, *values)

headers_to_skip: "Optional[Set[str]]" = getattr(
self, "_sentrysdk_headers_to_skip", None
)
if headers_to_skip is None:
return rv

if isinstance(header, bytes):
normalized_header = header.decode("ascii", "ignore").lower()
elif isinstance(header, str):
normalized_header = header.lower()
else:
return rv

# existing `sentry-trace`: skip so it is not duplicated.
if normalized_header == SENTRY_TRACE_HEADER_NAME:
headers_to_skip.add(normalized_header)

if normalized_header == "authorization" and values:
with capture_internal_exceptions():
authorization = values[0]
if isinstance(authorization, bytes):
authorization = authorization.decode("latin-1")
# `SignedHeaders` lists fields covered by SigV4.
signed_headers = (
_get_aws_sigv4_signed_headers_from_authorization_header(
authorization
)
)
# skip signed `sentry-trace` and `baggage`.
headers_to_skip.update(
_SENTRY_HEADER_NAMES.intersection(signed_headers)
)

return rv

return putheader


def _get_wrapped_endheaders(
original_endheaders: "Callable[..., Any]",
) -> "Callable[..., Any]":
"""
Add unsigned propagation headers immediately before sending.

`sentry-trace`: add if missing; leave an existing or signed value as-is.
`baggage`: add if missing; append if unsigned since multiple fields are
allowed and combined (https://www.w3.org/TR/baggage/#baggage-http-header-format);
leave a signed value as-is since it is part of the SigV4 signature.
"""

def endheaders(self: "HTTPConnection", *args: "Any", **kwargs: "Any") -> "Any":
real_url = getattr(self, "_sentrysdk_real_url", None)
span = getattr(self, "_sentrysdk_span", None)

try:
if real_url is not None:
with capture_internal_exceptions():
headers_to_skip = getattr(self, "_sentrysdk_headers_to_skip", None)

if headers_to_skip is not None:
# presigned URLs list signed names in the query string.
query_signed_headers = (
_get_aws_sigv4_signed_headers_from_url_query_string(
real_url
)
)
headers_to_skip.update(
_SENTRY_HEADER_NAMES.intersection(query_signed_headers)
)

for (
header_name,
header_value,
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(
span=span
):
# skip signed headers and an existing `sentry-trace`.
if header_name.lower() in headers_to_skip:
continue

logger.debug(
"[Tracing] Adding `{key}` header {value} to outgoing request to {real_url}.".format(
key=header_name,
value=header_value,
real_url=real_url,
)
)
self.putheader(header_name, header_value)
return original_endheaders(self, *args, **kwargs)
finally:
self._sentrysdk_real_url = None # type: ignore[attr-defined]

return endheaders


def _get_wrapped_putrequest(
original_putrequest: "Callable[..., Any]",
) -> "Callable[..., Any]":
"""Start `headers_to_skip` before headers are written."""

def putrequest(
self: "HTTPConnection", method: str, url: str, *args: "Any", **kwargs: "Any"
) -> "Any":
# existing `sentry-trace` and SigV4-signed propagation headers are skipped.
self._sentrysdk_headers_to_skip = set() # type: ignore[attr-defined]

try:
rv = original_putrequest(self, method, url, *args, **kwargs)
except BaseException:
# do not leave partial request state on a reusable connection when
# the underlying request setup fails.
self._sentrysdk_headers_to_skip = None # type: ignore[attr-defined]
raise

return rv

return putrequest


def _patch_aws_connection() -> None:
"""
Patch AWS connection classes so trace propagation does not break SigV4.

Botocore signs the request before headers reach HTTPConnection. Wait until
`endheaders()` to add unsigned propagation headers, after `putheader()` has
recorded existing `sentry-trace` and signed field names.
"""
if AWSHTTPConnection is not None:
AWSHTTPConnection.putheader = _get_wrapped_putheader( # type: ignore[method-assign]
AWSHTTPConnection.putheader
)
AWSHTTPConnection.endheaders = _get_wrapped_endheaders( # type: ignore[method-assign]
AWSHTTPConnection.endheaders
)
AWSHTTPConnection.putrequest = _get_wrapped_putrequest( # type: ignore[method-assign]
AWSHTTPConnection.putrequest
)

if AWSHTTPSConnection is not None:
AWSHTTPSConnection.putheader = _get_wrapped_putheader( # type: ignore[method-assign]
AWSHTTPSConnection.putheader
)
AWSHTTPSConnection.endheaders = _get_wrapped_endheaders( # type: ignore[method-assign]
AWSHTTPSConnection.endheaders
)
AWSHTTPSConnection.putrequest = _get_wrapped_putrequest( # type: ignore[method-assign]
AWSHTTPSConnection.putrequest
)


def _install_httplib() -> None:
Comment thread
sentry-warden[bot] marked this conversation as resolved.
real_putrequest = HTTPConnection.putrequest
real_getresponse = HTTPConnection.getresponse
Expand Down Expand Up @@ -189,7 +357,11 @@ def putrequest(

rv = real_putrequest(self, method, url, *args, **kwargs)

if should_propagate_trace(client, real_url):
# if `_sentrysdk_headers_to_skip` is set, inject headers in `endheaders()`
# after botocore has supplied the final headers.
if should_propagate_trace(client, real_url) and not hasattr(
self, "_sentrysdk_headers_to_skip"
):
for (
key,
value,
Expand All @@ -202,6 +374,8 @@ def putrequest(
)
)
self.putheader(key, value)
elif should_propagate_trace(client, real_url):
self._sentrysdk_real_url = real_url # type: ignore[attr-defined]

self._sentrysdk_span = span # type: ignore[attr-defined]
self._sentrysdk_breadcrumb = breadcrumb # type: ignore[attr-defined]
Expand Down Expand Up @@ -286,6 +460,9 @@ def close(self: "HTTPResponse") -> None:
_complete_span(span)

HTTPConnection.putrequest = putrequest # type: ignore[method-assign]
# patch `HTTPConnection.putrequest()` first. urllib3 < 1.25.9 inherits it, so

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'd also add a note that once boto3-v1.16.63 support is dropped we don't need to worry about this

# wrapping AWS classes first would skip URL and span recording.
_patch_aws_connection()
HTTPConnection.getresponse = getresponse # type: ignore[method-assign]
HTTPResponse.read = read # type: ignore[method-assign]
HTTPResponse.close = close # type: ignore[assignment,method-assign]
Expand Down
37 changes: 37 additions & 0 deletions sentry_sdk/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@

FALSY_ENV_VALUES = frozenset(("false", "f", "n", "no", "off", "0"))
TRUTHY_ENV_VALUES = frozenset(("true", "t", "y", "yes", "on", "1"))
_AWS_SIGV4_SIGNING_ALGORITHMS = frozenset(
("AWS4-HMAC-SHA256", "AWS4-ECDSA-P256-SHA256")
)

MAX_STACK_FRAMES = 2000
"""Maximum number of stack frames to send to Sentry.
Expand Down Expand Up @@ -1697,6 +1700,40 @@ def parse_url(url: str, sanitize: bool = True) -> "ParsedUrl":
)


def _get_aws_sigv4_signed_headers_from_authorization_header(
authorization: str,
) -> "Set[str]":
# only AWS SigV4 authorization has the SignedHeaders parameter.
value = authorization.lstrip()
algorithm, _, parameters = value.partition(" ")
if algorithm not in _AWS_SIGV4_SIGNING_ALGORITHMS:
return set()

for part in parameters.split(","):
part = part.strip()
if part.startswith("SignedHeaders="):
_, _, header_names = part.partition("=")
return {header.lower() for header in header_names.split(";") if header}

return set()


def _get_aws_sigv4_signed_headers_from_url_query_string(url: str) -> "Set[str]":
query = {
key.lower(): values for key, values in parse_qs(urlsplit(url).query).items()
}
algorithm = query.get("x-amz-algorithm", [""])[0]
if algorithm not in _AWS_SIGV4_SIGNING_ALGORITHMS:
return set()

# presigned requests have SignedHeaders in the URL query.
return {
header.lower()
for header in query.get("x-amz-signedheaders", [""])[0].split(";")
if header
}


def is_valid_sample_rate(rate: "Any", source: str) -> bool:
"""
Checks the given sample rate to make sure it is valid type and value (a
Expand Down
Loading
Loading