Skip to content
29 changes: 29 additions & 0 deletions sentry_sdk/integrations/_asgi_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import Union
from typing_extensions import Literal

from sentry_sdk._types import Attributes
from sentry_sdk.utils import AnnotatedValue


Expand Down Expand Up @@ -105,3 +106,31 @@
request_data["env"] = {"REMOTE_ADDR": _get_ip(asgi_scope)}

return request_data


def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]":
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is an attributes based copy of _get_request_data just above

"""
Return attributes related to the HTTP request from the ASGI scope.
"""
attributes: "Attributes" = {}

ty = asgi_scope["type"]
if ty in ("http", "websocket"):
if asgi_scope.get("method"):
attributes["http.request.method"] = asgi_scope["method"].upper()

headers = _filter_headers(_get_headers(asgi_scope), use_annotated_value=False)
for header, value in headers.items():
attributes[f"http.request.header.{header.lower()}"] = value

attributes["http.query"] = _get_query(asgi_scope)

Check warning on line 126 in sentry_sdk/integrations/_asgi_common.py

View check run for this annotation

@sentry/warden / warden: code-review

http.query attribute set to string "None" when no query string present

When there's no query string in the request, `_get_query(asgi_scope)` returns `None`, which is unconditionally assigned to `attributes["http.query"]`. When this `None` value is later passed through `format_attribute()` in `set_attribute()`, it becomes the string `"None"` via `safe_repr()`. This causes requests without query strings to have `http.query` set to the literal string `"None"` instead of omitting the attribute entirely.
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

http.query attribute set to string "None" when no query string present

When there's no query string in the request, _get_query(asgi_scope) returns None, which is unconditionally assigned to attributes["http.query"]. When this None value is later passed through format_attribute() in set_attribute(), it becomes the string "None" via safe_repr(). This causes requests without query strings to have http.query set to the literal string "None" instead of omitting the attribute entirely.

Verification

Verified by reading _get_query function (lines 60-67) which returns None when query_string is falsy, format_attribute in utils.py (lines 2114-2139) which falls through to safe_repr(val) for None, and safe_repr (lines 562-566) which returns repr(value) - for None this is the string "None".

Suggested fix: Only set the http.query attribute when a query string is present

Suggested change
attributes["http.query"] = _get_query(asgi_scope)
query = _get_query(asgi_scope)
if query is not None:
attributes["http.query"] = query

Identified by Warden code-review · ETC-LFX


attributes["url.full"] = _get_url(
asgi_scope, "http" if ty == "http" else "ws", headers.get("host")
)

client = asgi_scope.get("client")
if client and should_send_default_pii():
attributes["client.address"] = _get_ip(asgi_scope)

return attributes
13 changes: 8 additions & 5 deletions sentry_sdk/integrations/_wsgi_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from copy import deepcopy

import sentry_sdk
from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.utils import AnnotatedValue, logger

Expand Down Expand Up @@ -211,16 +212,18 @@ def _is_json_content_type(ct: "Optional[str]") -> bool:

def _filter_headers(
headers: "Mapping[str, str]",
use_annotated_value: bool = True,
) -> "Mapping[str, Union[AnnotatedValue, str]]":
if should_send_default_pii():
return headers

if use_annotated_value:
substitute = AnnotatedValue.removed_because_over_size_limit()
else:
substitute = SENSITIVE_DATA_SUBSTITUTE

return {
k: (
v
if k.upper().replace("-", "_") not in SENSITIVE_HEADERS
else AnnotatedValue.removed_because_over_size_limit()
)
k: (v if k.upper().replace("-", "_") not in SENSITIVE_HEADERS else substitute)
for k, v in headers.items()
}

Expand Down
60 changes: 58 additions & 2 deletions sentry_sdk/integrations/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from sentry_sdk.consts import OP
from sentry_sdk.integrations._asgi_common import (
_get_headers,
_get_request_attributes,
_get_request_data,
_get_url,
)
Expand All @@ -23,7 +24,11 @@
nullcontext,
)
from sentry_sdk.sessions import track_session
from sentry_sdk.traces import StreamedSpan
from sentry_sdk.traces import (
StreamedSpan,
SegmentSource,
SOURCE_FOR_STYLE as SEGMENT_SOURCE_FOR_STYLE,
)
from sentry_sdk.tracing import (
SOURCE_FOR_STYLE,
Transaction,
Expand All @@ -40,6 +45,7 @@
_get_installed_modules,
reraise,
capture_internal_exceptions,
qualname_from_function,
)

from typing import TYPE_CHECKING
Expand Down Expand Up @@ -235,7 +241,7 @@
transaction_source, "value", transaction_source
),
"sentry.origin": self.span_origin,
"asgi.type": ty,
"network.protocol.name": ty,
}

if ty in ("http", "websocket"):
Expand Down Expand Up @@ -301,6 +307,9 @@
else nullcontext()
)

for attribute, value in _get_request_attributes(scope).items():
sentry_scope.set_attribute(attribute, value)

with span_ctx as span:
try:

Expand Down Expand Up @@ -336,6 +345,7 @@
return await self.app(
scope, receive, _sentry_wrapped_send
)

except Exception as exc:
suppress_chained_exceptions = (
sentry_sdk.get_client()
Expand All @@ -350,6 +360,15 @@
with capture_internal_exceptions():
self._capture_request_exception(exc)
reraise(*exc_info)

finally:
with capture_internal_exceptions():
name, source = self._get_segment_name_and_source(
self.transaction_style, scope
)
if isinstance(span, StreamedSpan):
span.name = name
span.set_attribute("sentry.span.source", source)

Check warning on line 371 in sentry_sdk/integrations/asgi.py

View check run for this annotation

@sentry/warden / warden: find-bugs

Custom span names may be unconditionally overwritten for StreamedSpan

The finally block at lines 369-371 unconditionally sets the span name and source from `_get_segment_name_and_source()` without checking if a custom name/source was already set by the application (e.g., via `set_transaction_name()`). This contrasts with the `event_processor` method (lines 385-400) which has an `already_set` check that respects custom sources (COMPONENT, ROUTE, CUSTOM). This could cause applications that set custom transaction names to have them overwritten with URL-derived names.
finally:
_asgi_middleware_applied.set(False)

Expand Down Expand Up @@ -424,3 +443,40 @@
return name, source

return name, source

def _get_segment_name_and_source(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is a copy of _get_transaction_name_and_source above, just adapted for segments

self: "SentryAsgiMiddleware", segment_style: str, asgi_scope: "Any"
) -> "Tuple[str, str]":
name = None
source = SEGMENT_SOURCE_FOR_STYLE[segment_style].value
ty = asgi_scope.get("type")

if segment_style == "endpoint":
endpoint = asgi_scope.get("endpoint")
# Webframeworks like Starlette mutate the ASGI env once routing is
# done, which is sometime after the request has started. If we have
# an endpoint, overwrite our generic transaction name.
if endpoint:
name = qualname_from_function(endpoint) or ""
else:
name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
source = SegmentSource.URL.value

elif segment_style == "url":
# FastAPI includes the route object in the scope to let Sentry extract the
# path from it for the transaction name
route = asgi_scope.get("route")
if route:
path = getattr(route, "path", None)
if path is not None:
name = path
else:
name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
source = SegmentSource.URL.value

if name is None:
name = _DEFAULT_TRANSACTION_NAME
source = SegmentSource.ROUTE.value
return name, source

return name, source
42 changes: 42 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import brotli
import gzip
import io
from dataclasses import dataclass
from threading import Thread
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, HTTPServer
Expand Down Expand Up @@ -320,6 +321,47 @@ def append_envelope(envelope):
return inner


@dataclass
class UnwrappedItem:
type: str
payload: dict


@pytest.fixture
def capture_items(monkeypatch):
"""Capture envelope payload, unfurling individual items."""

def inner(*types):
telemetry = []
test_client = sentry_sdk.get_client()
old_capture_envelope = test_client.transport.capture_envelope

def append_envelope(envelope):
for item in envelope:
if types and item.type not in types:
continue

if item.type in ("metric", "log", "span"):
for json in item.payload.json["items"]:
t = {k: v for k, v in json.items() if k != "attributes"}
t["attributes"] = {
k: v["value"] for k, v in json["attributes"].items()
}
telemetry.append(UnwrappedItem(type=item.type, payload=t))
else:
telemetry.append(
UnwrappedItem(type=item.type, payload=item.payload.json)
)

return old_capture_envelope(envelope)

monkeypatch.setattr(test_client.transport, "capture_envelope", append_envelope)

return telemetry

return inner


@pytest.fixture
def capture_record_lost_event_calls(monkeypatch):
def inner():
Expand Down
Loading
Loading