Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
282ba35
feat(asgi): Migrate away from event processor in span first
sentrivana Mar 30, 2026
e2484bd
fixes
sentrivana Mar 30, 2026
51af434
Merge branch 'master' into ivana/migrate-asgi-event-processor
sentrivana Mar 30, 2026
5c3174f
.
sentrivana Mar 30, 2026
b270e5a
.
sentrivana Apr 1, 2026
e93fd1b
.
sentrivana Apr 1, 2026
7cde973
capture_items
sentrivana Apr 2, 2026
ef9e640
Merge branch 'master' into ivana/migrate-asgi-event-processor
sentrivana Apr 2, 2026
df3f3af
.
sentrivana Apr 2, 2026
babb3bd
.
sentrivana Apr 2, 2026
75429dc
no annotatedvalues
sentrivana Apr 2, 2026
d9d7674
.
sentrivana Apr 2, 2026
32e4191
.
sentrivana Apr 2, 2026
5cecf97
more tests
sentrivana Apr 2, 2026
a39337b
Merge branch 'master' into ivana/migrate-asgi-event-processor
sentrivana Apr 2, 2026
7fb4863
.
sentrivana Apr 2, 2026
7490fe3
.
sentrivana Apr 2, 2026
c11e8f1
.
sentrivana Apr 2, 2026
982d471
Merge branch 'master' into ivana/migrate-asgi-event-processor
sentrivana Apr 13, 2026
10bf7c9
ruff
sentrivana Apr 13, 2026
87d00e6
source is not default anymore
sentrivana Apr 13, 2026
507306f
Merge branch 'master' into ivana/migrate-asgi-event-processor
sentrivana Apr 13, 2026
eebc385
mypy
sentrivana Apr 13, 2026
e388901
var name
sentrivana Apr 13, 2026
77b9298
Merge branch 'master' into ivana/migrate-asgi-event-processor
sentrivana Apr 13, 2026
2202e0c
set attrs directly on span, not scope
sentrivana Apr 13, 2026
4aaf9d4
Merge branch 'master' into ivana/migrate-asgi-event-processor
sentrivana Apr 13, 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
30 changes: 30 additions & 0 deletions sentry_sdk/integrations/_asgi_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,33 @@ def _get_request_data(asgi_scope: "Any") -> "Dict[str, Any]":
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: "dict[str, Any]" = {}

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

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

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
14 changes: 9 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,19 @@ 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

substitute: "Union[AnnotatedValue, str]"
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
76 changes: 74 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 @@ async def _run_app(
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 @@ -302,6 +308,12 @@ async def _run_app(
)

with span_ctx as span:
if isinstance(span, StreamedSpan):
for attribute, value in _get_request_attributes(
scope
).items():
span.set_attribute(attribute, value)

try:

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

except Exception as exc:
suppress_chained_exceptions = (
sentry_sdk.get_client()
Expand All @@ -350,6 +363,28 @@ async def _sentry_wrapped_send(
with capture_internal_exceptions():
self._capture_request_exception(exc)
reraise(*exc_info)

finally:
if isinstance(span, StreamedSpan):
already_set = (
span is not None
and span.name != _DEFAULT_TRANSACTION_NAME
and span.get_attributes().get("sentry.span.source")
in [
SegmentSource.COMPONENT.value,
SegmentSource.ROUTE.value,
SegmentSource.CUSTOM.value,
]
)
with capture_internal_exceptions():
if not already_set:
name, source = (
self._get_segment_name_and_source(
self.transaction_style, scope
)
)
span.name = name
span.set_attribute("sentry.span.source", source)
finally:
_asgi_middleware_applied.set(False)

Expand Down Expand Up @@ -424,3 +459,40 @@ def _get_transaction_name_and_source(
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
2 changes: 1 addition & 1 deletion sentry_sdk/traces.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ def __init__(
self._name: str = name
self._active: bool = active
self._attributes: "Attributes" = {}

if attributes:
for attribute, value in attributes.items():
self.set_attribute(attribute, value)
Expand Down Expand Up @@ -287,7 +288,6 @@ def __init__(
self._span_id: "Optional[str]" = None

self._status = SpanStatus.OK.value
self.set_attribute("sentry.span.source", SegmentSource.CUSTOM.value)

self._update_active_thread()

Expand Down
47 changes: 47 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,52 @@
return inner


@dataclass
class UnwrappedItem:
type: str
payload: dict


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

Makes it easier to work with both events and attribute-based telemetry in
one test.
"""

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 i in item.payload.json["items"]:
t = {k: v for k, v in i.items() if k != "attributes"}
t["attributes"] = {
k: v["value"] for k, v in i["attributes"].items()
}

Check warning on line 354 in tests/conftest.py

View check run for this annotation

@sentry/warden / warden: find-bugs

capture_items fixture crashes with KeyError for spans without attributes

The code at lines 352-354 unconditionally accesses `i["attributes"]` for span items. However, looking at `_span_batcher.py:124-127`, the `attributes` key is only added to span items if `item._attributes` is truthy (non-empty). When a span has no attributes, accessing `i["attributes"]` will raise a `KeyError`, causing the test fixture to fail when capturing attribute-less spans.

Check warning on line 354 in tests/conftest.py

View check run for this annotation

@sentry/warden / warden: code-review

KeyError when processing spans without attributes

The `capture_items` fixture assumes all span items have an `attributes` key at line 353, but `_span_batcher._to_transport_format` only adds `attributes` to the result dict conditionally (when `item._attributes` is truthy). Spans without attributes will cause a `KeyError` when tests using this fixture process them, leading to unexpected test failures.
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