Skip to content

Commit a0235d2

Browse files
authored
feat(asgi): Make integration fully span first compatible (#5920)
In span first, there are no event processors. Therefore, we need to be able to set the data we were setting in event processors differently. As we migrate our integrations one by one, this will be an exercise in whether it's possible to achieve this without some sort of callback/lifecycle hooks. So far, in ASGI, it seems we can get by by simply using `scope.set_attribute()` for setting request related data, and updating the segment name/source just before the span ends. Adding this enables us to actually test the new functionality. To sum up, this PR: - makes the ASGI integration fully span-first-compatible: data set on spans currently should appear as attributes in span first - introduces a test helper that makes it easier to unpack envelope items (useful especially when a test checks both old- and new-style telemetry) - fully tests the ASGI span streaming implementation Like in other span first PRs, there is quite a bit of intentional code duplication so that it's easier to remove the legacy implementation in the next major. #### Issues * Closes #5722 * Closes https://linear.app/getsentry/issue/PY-2152/event-processor-replacement-for-streamed-spans
1 parent f9f37d5 commit a0235d2

File tree

7 files changed

+541
-106
lines changed

7 files changed

+541
-106
lines changed

sentry_sdk/integrations/_asgi_common.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,33 @@ def _get_request_data(asgi_scope: "Any") -> "Dict[str, Any]":
105105
request_data["env"] = {"REMOTE_ADDR": _get_ip(asgi_scope)}
106106

107107
return request_data
108+
109+
110+
def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]":
111+
"""
112+
Return attributes related to the HTTP request from the ASGI scope.
113+
"""
114+
attributes: "dict[str, Any]" = {}
115+
116+
ty = asgi_scope["type"]
117+
if ty in ("http", "websocket"):
118+
if asgi_scope.get("method"):
119+
attributes["http.request.method"] = asgi_scope["method"].upper()
120+
121+
headers = _filter_headers(_get_headers(asgi_scope), use_annotated_value=False)
122+
for header, value in headers.items():
123+
attributes[f"http.request.header.{header.lower()}"] = value
124+
125+
query = _get_query(asgi_scope)
126+
if query:
127+
attributes["http.query"] = query
128+
129+
attributes["url.full"] = _get_url(
130+
asgi_scope, "http" if ty == "http" else "ws", headers.get("host")
131+
)
132+
133+
client = asgi_scope.get("client")
134+
if client and should_send_default_pii():
135+
attributes["client.address"] = _get_ip(asgi_scope)
136+
137+
return attributes

sentry_sdk/integrations/_wsgi_common.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from copy import deepcopy
44

55
import sentry_sdk
6+
from sentry_sdk._types import SENSITIVE_DATA_SUBSTITUTE
67
from sentry_sdk.scope import should_send_default_pii
78
from sentry_sdk.utils import AnnotatedValue, logger
89

@@ -211,16 +212,19 @@ def _is_json_content_type(ct: "Optional[str]") -> bool:
211212

212213
def _filter_headers(
213214
headers: "Mapping[str, str]",
215+
use_annotated_value: bool = True,
214216
) -> "Mapping[str, Union[AnnotatedValue, str]]":
215217
if should_send_default_pii():
216218
return headers
217219

220+
substitute: "Union[AnnotatedValue, str]"
221+
if use_annotated_value:
222+
substitute = AnnotatedValue.removed_because_over_size_limit()
223+
else:
224+
substitute = SENSITIVE_DATA_SUBSTITUTE
225+
218226
return {
219-
k: (
220-
v
221-
if k.upper().replace("-", "_") not in SENSITIVE_HEADERS
222-
else AnnotatedValue.removed_because_over_size_limit()
223-
)
227+
k: (v if k.upper().replace("-", "_") not in SENSITIVE_HEADERS else substitute)
224228
for k, v in headers.items()
225229
}
226230

sentry_sdk/integrations/asgi.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from sentry_sdk.consts import OP
1616
from sentry_sdk.integrations._asgi_common import (
1717
_get_headers,
18+
_get_request_attributes,
1819
_get_request_data,
1920
_get_url,
2021
)
@@ -23,7 +24,11 @@
2324
nullcontext,
2425
)
2526
from sentry_sdk.sessions import track_session
26-
from sentry_sdk.traces import StreamedSpan
27+
from sentry_sdk.traces import (
28+
StreamedSpan,
29+
SegmentSource,
30+
SOURCE_FOR_STYLE as SEGMENT_SOURCE_FOR_STYLE,
31+
)
2732
from sentry_sdk.tracing import (
2833
SOURCE_FOR_STYLE,
2934
Transaction,
@@ -40,6 +45,7 @@
4045
_get_installed_modules,
4146
reraise,
4247
capture_internal_exceptions,
48+
qualname_from_function,
4349
)
4450

4551
from typing import TYPE_CHECKING
@@ -235,7 +241,7 @@ async def _run_app(
235241
transaction_source, "value", transaction_source
236242
),
237243
"sentry.origin": self.span_origin,
238-
"asgi.type": ty,
244+
"network.protocol.name": ty,
239245
}
240246

241247
if ty in ("http", "websocket"):
@@ -302,6 +308,12 @@ async def _run_app(
302308
)
303309

304310
with span_ctx as span:
311+
if isinstance(span, StreamedSpan):
312+
for attribute, value in _get_request_attributes(
313+
scope
314+
).items():
315+
span.set_attribute(attribute, value)
316+
305317
try:
306318

307319
async def _sentry_wrapped_send(
@@ -336,6 +348,7 @@ async def _sentry_wrapped_send(
336348
return await self.app(
337349
scope, receive, _sentry_wrapped_send
338350
)
351+
339352
except Exception as exc:
340353
suppress_chained_exceptions = (
341354
sentry_sdk.get_client()
@@ -350,6 +363,28 @@ async def _sentry_wrapped_send(
350363
with capture_internal_exceptions():
351364
self._capture_request_exception(exc)
352365
reraise(*exc_info)
366+
367+
finally:
368+
if isinstance(span, StreamedSpan):
369+
already_set = (
370+
span is not None
371+
and span.name != _DEFAULT_TRANSACTION_NAME
372+
and span.get_attributes().get("sentry.span.source")
373+
in [
374+
SegmentSource.COMPONENT.value,
375+
SegmentSource.ROUTE.value,
376+
SegmentSource.CUSTOM.value,
377+
]
378+
)
379+
with capture_internal_exceptions():
380+
if not already_set:
381+
name, source = (
382+
self._get_segment_name_and_source(
383+
self.transaction_style, scope
384+
)
385+
)
386+
span.name = name
387+
span.set_attribute("sentry.span.source", source)
353388
finally:
354389
_asgi_middleware_applied.set(False)
355390

@@ -424,3 +459,40 @@ def _get_transaction_name_and_source(
424459
return name, source
425460

426461
return name, source
462+
463+
def _get_segment_name_and_source(
464+
self: "SentryAsgiMiddleware", segment_style: str, asgi_scope: "Any"
465+
) -> "Tuple[str, str]":
466+
name = None
467+
source = SEGMENT_SOURCE_FOR_STYLE[segment_style].value
468+
ty = asgi_scope.get("type")
469+
470+
if segment_style == "endpoint":
471+
endpoint = asgi_scope.get("endpoint")
472+
# Webframeworks like Starlette mutate the ASGI env once routing is
473+
# done, which is sometime after the request has started. If we have
474+
# an endpoint, overwrite our generic transaction name.
475+
if endpoint:
476+
name = qualname_from_function(endpoint) or ""
477+
else:
478+
name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
479+
source = SegmentSource.URL.value
480+
481+
elif segment_style == "url":
482+
# FastAPI includes the route object in the scope to let Sentry extract the
483+
# path from it for the transaction name
484+
route = asgi_scope.get("route")
485+
if route:
486+
path = getattr(route, "path", None)
487+
if path is not None:
488+
name = path
489+
else:
490+
name = _get_url(asgi_scope, "http" if ty == "http" else "ws", host=None)
491+
source = SegmentSource.URL.value
492+
493+
if name is None:
494+
name = _DEFAULT_TRANSACTION_NAME
495+
source = SegmentSource.ROUTE.value
496+
return name, source
497+
498+
return name, source

sentry_sdk/traces.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ def __init__(
259259
self._name: str = name
260260
self._active: bool = active
261261
self._attributes: "Attributes" = {}
262+
262263
if attributes:
263264
for attribute, value in attributes.items():
264265
self.set_attribute(attribute, value)
@@ -287,7 +288,6 @@ def __init__(
287288
self._span_id: "Optional[str]" = None
288289

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

292292
self._update_active_thread()
293293

tests/conftest.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import brotli
88
import gzip
99
import io
10+
from dataclasses import dataclass
1011
from threading import Thread
1112
from contextlib import contextmanager
1213
from http.server import BaseHTTPRequestHandler, HTTPServer
@@ -320,6 +321,52 @@ def append_envelope(envelope):
320321
return inner
321322

322323

324+
@dataclass
325+
class UnwrappedItem:
326+
type: str
327+
payload: dict
328+
329+
330+
@pytest.fixture
331+
def capture_items(monkeypatch):
332+
"""
333+
Capture envelope payload, unfurling individual items.
334+
335+
Makes it easier to work with both events and attribute-based telemetry in
336+
one test.
337+
"""
338+
339+
def inner(*types):
340+
telemetry = []
341+
test_client = sentry_sdk.get_client()
342+
old_capture_envelope = test_client.transport.capture_envelope
343+
344+
def append_envelope(envelope):
345+
for item in envelope:
346+
if types and item.type not in types:
347+
continue
348+
349+
if item.type in ("metric", "log", "span"):
350+
for i in item.payload.json["items"]:
351+
t = {k: v for k, v in i.items() if k != "attributes"}
352+
t["attributes"] = {
353+
k: v["value"] for k, v in i["attributes"].items()
354+
}
355+
telemetry.append(UnwrappedItem(type=item.type, payload=t))
356+
else:
357+
telemetry.append(
358+
UnwrappedItem(type=item.type, payload=item.payload.json)
359+
)
360+
361+
return old_capture_envelope(envelope)
362+
363+
monkeypatch.setattr(test_client.transport, "capture_envelope", append_envelope)
364+
365+
return telemetry
366+
367+
return inner
368+
369+
323370
@pytest.fixture
324371
def capture_record_lost_event_calls(monkeypatch):
325372
def inner():

0 commit comments

Comments
 (0)