Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
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
193 changes: 190 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, Tuple, 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]

Check failure on line 51 in sentry_sdk/integrations/stdlib.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[YV7-9QK] AWS putrequest wrapper shadows stdlib HTTPConnection instrumentation (additional location)

Wrap AWS putrequest after assigning HTTPConnection.putrequest (or compose with that patched method) so AWS connections still create spans and set `_sentrysdk_trace_url`/`_sentrysdk_span` for deferred endheaders injection.


class StdlibIntegration(Integration):
identifier = "stdlib"

Check failure on line 55 in sentry_sdk/integrations/stdlib.py

View check run for this annotation

@sentry/warden / warden: code-review

AWS putrequest patch shadows stdlib HTTPConnection instrumentation

Patch AWS putrequest after assigning HTTPConnection.putrequest, or wrap that patched method, so AWS connections still create spans and set _sentrysdk_trace_url/_sentrysdk_span.
Expand Down Expand Up @@ -73,7 +83,178 @@
add_http_request_source(span)


def _get_wrapped_putheader(
original_putheader: "Callable[..., Any]",
) -> "Callable[..., Any]":
"""
Responsible for tracking which sentry headers are present and whether
they are listed in AWS SigV4 `SignedHeaders`.
"""

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

request_headers: "Optional[Dict[str, Tuple[bool, bool]]]" = getattr(
self, "_sentrysdk_request_headers", None
)
if request_headers 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

if normalized_header in _SENTRY_HEADER_NAMES:
_, is_signed = request_headers.get(normalized_header, (False, False))
request_headers[normalized_header] = (True, is_signed)

if normalized_header == "authorization" and values:
with capture_internal_exceptions():
authorization = values[0]
if isinstance(authorization, bytes):
authorization = authorization.decode("latin-1")
for (
signed_header
) in _get_aws_sigv4_signed_headers_from_authorization_header(
authorization
):
if signed_header in _SENTRY_HEADER_NAMES:
is_present, _ = request_headers.get(
signed_header, (False, False)
)
request_headers[signed_header] = (is_present, True)

return rv

return putheader


def _get_wrapped_endheaders(
original_endheaders: "Callable[..., Any]",
) -> "Callable[..., Any]":
"""
Responsible for injecting trace propagation headers, ensuring that the request is not invalidated
by honoring signed headers.
"""

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

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.

Does real_url represent the original URL in the request? Or should this instead be called trace_url?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

yes, it represents the whole URL, but its mainly used for trace propagation. _sentrysdk_real_url is better though.

span = getattr(self, "_sentrysdk_span", None)

try:
if real_url is not None:
with capture_internal_exceptions():
request_headers: "Optional[Dict[str, Tuple[bool, bool]]]" = getattr(
self, "_sentrysdk_request_headers", {}
)
if request_headers is not None:
Comment on lines +151 to +153

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.

If _sentrysdk_request_headers is not set on self and the empty dict default is returned, the if request_headers is not None conditional will still pass.

If this is the intent, it'd be clearer to have the default value for getattr be None as the way that this is currently written, this looks like a potential bug 😅

for (
signed_header
) in _get_aws_sigv4_signed_headers_from_url_query_string(
real_url
):
if signed_header in _SENTRY_HEADER_NAMES:
is_present, _ = request_headers.get(
signed_header, (False, False)
)
request_headers[signed_header] = (is_present, True)
Comment on lines +154 to +163

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'm finding this block of code (and the related one further up that deals with the authorization header) a bit confusing.

In what circumstances would the signed_header not be found in request_headers but is present on the query string, such that is_present would be False (lines 160-62)?

Related to that - do we need the request_headers.get() part that sets is_present? If the signed header is in the query string, would that not mean that, at this point in the code, is_present is always true?


for (
header_name,
header_value,
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(
span=span
):
normalized_header = header_name.lower()
# preserve signed headers and avoid duplicate `sentry-trace`.
is_present, is_signed = request_headers.get(
normalized_header, (False, False)
)
if is_signed or (
is_present and normalized_header != BAGGAGE_HEADER_NAME

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.

If we're looking to avoid a duplicate sentry-trace (the comment on line 172), should BAGGAGE_HEADER_NAME instead be SENTRY_TRACE_HEADER_NAME? Do we potentially need to avoid duplicating both of these headers?

):
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_trace_url = None # type: ignore[attr-defined]

return endheaders


def _get_wrapped_putrequest(
original_putrequest: "Callable[..., Any]",
) -> "Callable[..., Any]":
"""
Responsible for initializing request and signed header tracking on the instance.
"""

def putrequest(
self: "HTTPConnection", method: str, url: str, *args: "Any", **kwargs: "Any"
) -> "Any":
# track which propagation headers are present and signed e.g. {"sentry-trace": (is_present, is_signed)}
request_headers: "Optional[Dict[str, Tuple[bool, bool]]]" = {}
self._sentrysdk_request_headers = request_headers # type: ignore[attr-defined]
Comment on lines +206 to +208

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.

Was there code that was previously here that used request_headers? This looks like we could remove request_headers and assign self._sentrysdk_request_headers to an empty dict instead

Suggested change
# track which propagation headers are present and signed e.g. {"sentry-trace": (is_present, is_signed)}
request_headers: "Optional[Dict[str, Tuple[bool, bool]]]" = {}
self._sentrysdk_request_headers = request_headers # type: ignore[attr-defined]
self._sentrysdk_request_headers = {}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I was trying to fix a mypy error, but just did type: ignore[attr-defined] in the end and forgot to remove that part.


try:
rv = original_putrequest(self, method, url, *args, **kwargs)
except BaseException:
self._sentrysdk_request_headers = None # type: ignore[attr-defined]
raise

return rv

return putrequest


def _patch_aws_connection() -> None:
"""
Patch AWS connection classes. These classes provide functions to sign HTTP headers, and subsequently
injecting trace propagation headers would invalidate the request.

Detect whether propagation headers are present and signed by patching
`putheader()`. Store that state in `_sentrysdk_request_headers`, initialized
by the `putrequest()` patch.

Do not edit signed headers when adding trace propagation headers in the `endheaders()` patch.
"""
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

Check failure on line 251 in sentry_sdk/integrations/stdlib.py

View check run for this annotation

@sentry/warden / warden: find-bugs

AWS putrequest wrapper shadows stdlib HTTPConnection instrumentation

Wrap AWS putrequest after assigning HTTPConnection.putrequest (or compose with that patched method) so AWS connections still create spans and set `_sentrysdk_trace_url`/`_sentrysdk_span` for deferred endheaders injection.
)


def _install_httplib() -> None:

@sentry-warden sentry-warden Bot Aug 24, 2026

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.

AWS connection wrapper bypasses stdlib putrequest instrumentation

_patch_aws_connection() runs before HTTPConnection.putrequest is replaced. Because the AWS connection classes inherit putrequest, their wrapper captures the original uninstrumented method and then shadows the later base-class patch. As a result, botocore requests using only StdlibIntegration do not execute the stdlib instrumentation or set _sentrysdk_trace_url, so the deferred endheaders() path never injects Sentry propagation headers. Requests with Boto3Integration may still receive headers from its before-sign handler, but lose stdlib-level instrumentation.

Evidence
  • _install_httplib() calls _patch_aws_connection() before assigning the instrumented HTTPConnection.putrequest.
  • _patch_aws_connection() assigns an AWS-class putrequest wrapper that captures the then-current inherited, uninstrumented method.
  • The AWS-class method shadows the later HTTPConnection.putrequest assignment, so AWS requests skip the stdlib span setup and never set _sentrysdk_trace_url.
  • _get_wrapped_endheaders() injects propagation only when _sentrysdk_trace_url is present; therefore the Stdlib-only botocore path omits Sentry headers, as exercised by test_botocore_without_boto3_integration_preserves_signed_baggage.

Identified by Warden · code-review, find-bugs · 8UW-Y37

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.

no they call the superclass

@ericapisani ericapisani Aug 25, 2026

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.

From looking at the botocore code, I think this is legit. Here's a sample script of what it's describing. If you reverse the order of the Sub/Base patches, you'll see that when Sub is called that it also calls the patch on Base as well (right now it doesn't).

Edit: the solution would be to move the _patch_aws_connection call to below the HTTPConnection.* patches

demo_shadowing_bug.py

_patch_aws_connection()
Comment thread
alexander-alderman-webb marked this conversation as resolved.

real_putrequest = HTTPConnection.putrequest
real_getresponse = HTTPConnection.getresponse
real_read = HTTPResponse.read
Expand Down Expand Up @@ -189,19 +370,25 @@

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

if should_propagate_trace(client, real_url):
# If _sentrysdk_request_headers is present, trace propagation headers should
# be injected in an `endheaders()` patch.
if should_propagate_trace(client, real_url) and not hasattr(
self, "_sentrysdk_request_headers"
):
for (
key,
value,
) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(
span=span
):
logger.debug(
"[Tracing] Adding `{key}` header {value} to outgoing request to {real_url}.".format(
key=key, value=value, real_url=real_url
)
)
self.putheader(key, value)
elif should_propagate_trace(client, real_url):
self._sentrysdk_trace_url = real_url # type: ignore[attr-defined]

Check failure on line 391 in sentry_sdk/integrations/stdlib.py

View check run for this annotation

@sentry/warden / warden: code-review

[CH6-PH7] AWS putrequest patch shadows stdlib HTTPConnection instrumentation (additional location)

Patch AWS putrequest after assigning HTTPConnection.putrequest, or wrap that patched method, so AWS connections still create spans and set _sentrysdk_trace_url/_sentrysdk_span.

Check failure on line 391 in sentry_sdk/integrations/stdlib.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[YV7-9QK] AWS putrequest wrapper shadows stdlib HTTPConnection instrumentation (additional location)

Wrap AWS putrequest after assigning HTTPConnection.putrequest (or compose with that patched method) so AWS connections still create spans and set `_sentrysdk_trace_url`/`_sentrysdk_span` for deferred endheaders injection.
Comment thread
cursor[bot] marked this conversation as resolved.

self._sentrysdk_span = span # type: ignore[attr-defined]
self._sentrysdk_breadcrumb = breadcrumb # type: ignore[attr-defined]
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