Skip to content

Commit f33e407

Browse files
committed
feat(wsgi): Migrate WSGI integration to span first
1 parent 77b9298 commit f33e407

File tree

4 files changed

+174
-37
lines changed

4 files changed

+174
-37
lines changed

sentry_sdk/integrations/_asgi_common.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]":
132132

133133
client = asgi_scope.get("client")
134134
if client and should_send_default_pii():
135-
attributes["client.address"] = _get_ip(asgi_scope)
135+
ip = _get_ip(asgi_scope)
136+
attributes["client.address"] = ip
137+
attributes["user.ip_address"] = ip
136138

137139
return attributes

sentry_sdk/integrations/_wsgi_common.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,53 @@ def _in_http_status_code_range(
249249
return False
250250

251251

252+
def _get_request_attributes(
253+
environ: "Dict[str, str]",
254+
use_x_forwarded_for: bool = False,
255+
) -> "Dict[str, Any]":
256+
"""
257+
Return span attributes related to the HTTP request from the WSGI environ.
258+
"""
259+
from sentry_sdk._werkzeug import _get_headers
260+
from sentry_sdk.integrations.wsgi import get_client_ip, get_request_url
261+
262+
attributes: "Dict[str, Any]" = {}
263+
264+
method = environ.get("REQUEST_METHOD")
265+
if method:
266+
attributes["http.request.method"] = method.upper()
267+
268+
headers = _filter_headers(dict(_get_headers(environ)), use_annotated_value=False)
269+
for header, value in headers.items():
270+
attributes[f"http.request.header.{header.lower()}"] = value
271+
272+
query_string = environ.get("QUERY_STRING")
273+
if query_string:
274+
attributes["http.query"] = query_string
275+
276+
attributes["url.full"] = get_request_url(environ, use_x_forwarded_for)
277+
278+
url_scheme = environ.get("wsgi.url_scheme")
279+
if url_scheme:
280+
attributes["network.protocol.name"] = url_scheme
281+
282+
server_name = environ.get("SERVER_NAME")
283+
if server_name:
284+
attributes["server.address"] = server_name
285+
286+
server_port = environ.get("SERVER_PORT")
287+
if server_port:
288+
attributes["server.port"] = server_port
289+
290+
if should_send_default_pii():
291+
client_ip = get_client_ip(environ)
292+
if client_ip:
293+
attributes["client.address"] = client_ip
294+
attributes["user.ip_address"] = client_ip
295+
296+
return attributes
297+
298+
252299
class HttpCodeRangeContainer:
253300
"""
254301
Wrapper to make it possible to use list[HttpStatusCodeRange] as a Container[int].

sentry_sdk/integrations/wsgi.py

Lines changed: 83 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@
99
from sentry_sdk.integrations._wsgi_common import (
1010
DEFAULT_HTTP_METHODS_TO_CAPTURE,
1111
_filter_headers,
12+
_get_request_attributes,
1213
nullcontext,
1314
)
1415
from sentry_sdk.scope import should_send_default_pii, use_isolation_scope
1516
from sentry_sdk.sessions import track_session
17+
from sentry_sdk.traces import StreamedSpan, SegmentSource
1618
from sentry_sdk.tracing import Transaction, TransactionSource
19+
from sentry_sdk.tracing_utils import has_span_streaming_enabled
1720
from sentry_sdk.utils import (
1821
ContextVar,
1922
capture_internal_exceptions,
@@ -22,7 +25,18 @@
2225
)
2326

2427
if TYPE_CHECKING:
25-
from typing import Any, Callable, Dict, Iterator, Optional, Protocol, Tuple, TypeVar
28+
from typing import (
29+
Any,
30+
Callable,
31+
ContextManager,
32+
Dict,
33+
Iterator,
34+
Optional,
35+
Protocol,
36+
Tuple,
37+
TypeVar,
38+
Union,
39+
)
2640

2741
from sentry_sdk._types import Event, EventProcessor
2842
from sentry_sdk.utils import ExcInfo
@@ -42,6 +56,7 @@ def __call__(
4256

4357

4458
_wsgi_middleware_applied = ContextVar("sentry_wsgi_middleware_applied")
59+
_DEFAULT_TRANSACTION_NAME = "generic WSGI request"
4560

4661

4762
def wsgi_decoding_dance(s: str, charset: str = "utf-8", errors: str = "replace") -> str:
@@ -94,6 +109,9 @@ def __call__(
94109
if _wsgi_middleware_applied.get(False):
95110
return self.app(environ, start_response)
96111

112+
client = sentry_sdk.get_client()
113+
span_streaming = has_span_streaming_enabled(client.options)
114+
97115
_wsgi_middleware_applied.set(True)
98116
try:
99117
with sentry_sdk.isolation_scope() as scope:
@@ -108,34 +126,71 @@ def __call__(
108126
)
109127

110128
method = environ.get("REQUEST_METHOD", "").upper()
111-
transaction = None
129+
130+
span_ctx: "ContextManager[Union[Transaction, StreamedSpan, None]]" = None
112131
if method in self.http_methods_to_capture:
113-
transaction = continue_trace(
114-
environ,
115-
op=OP.HTTP_SERVER,
116-
name="generic WSGI request",
117-
source=TransactionSource.ROUTE,
118-
origin=self.span_origin,
119-
)
132+
if span_streaming:
133+
sentry_sdk.traces.continue_trace(
134+
dict(_get_headers(environ))
135+
)
136+
scope.set_custom_sampling_context({"wsgi_environ": scope})
137+
138+
span_ctx = sentry_sdk.traces.start_span(
139+
name=_DEFAULT_TRANSACTION_NAME,
140+
attributes={
141+
"sentry.span.source": SegmentSource.ROUTE,
142+
"sentry.origin": self.span_origin,
143+
"sentry.op": OP.HTTP_SERVER,
144+
},
145+
)
146+
else:
147+
transaction = continue_trace(
148+
environ,
149+
op=OP.HTTP_SERVER,
150+
name=_DEFAULT_TRANSACTION_NAME,
151+
source=TransactionSource.ROUTE,
152+
origin=self.span_origin,
153+
)
120154

121-
transaction_context = (
122-
sentry_sdk.start_transaction(
123-
transaction,
124-
custom_sampling_context={"wsgi_environ": environ},
125-
)
126-
if transaction is not None
127-
else nullcontext()
128-
)
129-
with transaction_context:
155+
span_ctx = sentry_sdk.start_transaction(
156+
transaction,
157+
custom_sampling_context={"wsgi_environ": environ},
158+
)
159+
160+
with capture_internal_exceptions():
161+
for attr, value in _get_request_attributes(
162+
environ, self.use_x_forwarded_for
163+
).items():
164+
scope.set_attribute(attr, value)
165+
166+
span_ctx = span_ctx or nullcontext()
167+
168+
with span_ctx as span:
130169
try:
131170
response = self.app(
132171
environ,
133-
partial(
134-
_sentry_start_response, start_response, transaction
135-
),
172+
partial(_sentry_start_response, start_response, span),
136173
)
137174
except BaseException:
138175
reraise(*_capture_exception())
176+
finally:
177+
if isinstance(span, StreamedSpan):
178+
already_set = (
179+
span.name != _DEFAULT_TRANSACTION_NAME
180+
and span.get_attributes().get("sentry.span.source")
181+
in [
182+
SegmentSource.COMPONENT.value,
183+
SegmentSource.ROUTE.value,
184+
SegmentSource.CUSTOM.value,
185+
]
186+
)
187+
if not already_set:
188+
with capture_internal_exceptions():
189+
span.name = _DEFAULT_TRANSACTION_NAME
190+
span.set_attribute(
191+
"sentry.span.source",
192+
SegmentSource.ROUTE.value,
193+
)
139194
finally:
140195
_wsgi_middleware_applied.set(False)
141196

@@ -167,15 +222,19 @@ def __call__(
167222

168223
def _sentry_start_response(
169224
old_start_response: "StartResponse",
170-
transaction: "Optional[Transaction]",
225+
span: "Optional[Union[Transaction, StreamedSpan]]",
171226
status: str,
172227
response_headers: "WsgiResponseHeaders",
173228
exc_info: "Optional[WsgiExcInfo]" = None,
174229
) -> "WsgiResponseIter": # type: ignore[type-var]
175230
with capture_internal_exceptions():
176231
status_int = int(status.split(" ", 1)[0])
177-
if transaction is not None:
178-
transaction.set_http_status(status_int)
232+
if span is not None:
233+
if isinstance(span, StreamedSpan):
234+
span.status = "error" if status_int >= 400 else "ok"
235+
span.set_attribute("http.response.status_code", status_int)
236+
else:
237+
span.set_http_status(status_int)
179238

180239
if exc_info is None:
181240
# The Django Rest Framework WSGI test client, and likely other

tests/integrations/wsgi/test_wsgi.py

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -139,26 +139,50 @@ def test_keyboard_interrupt_is_captured(sentry_init, capture_events):
139139
assert event["level"] == "error"
140140

141141

142+
@pytest.mark.parametrize("span_streaming", [True, False])
142143
def test_transaction_with_error(
143144
sentry_init,
144145
crashing_app,
145146
capture_events,
147+
capture_items,
146148
DictionaryContaining, # noqa:N803
149+
span_streaming,
147150
):
148151
def dogpark(environ, start_response):
149152
raise ValueError("Fetch aborted. The ball was not returned.")
150153

151-
sentry_init(send_default_pii=True, traces_sample_rate=1.0)
154+
sentry_init(
155+
send_default_pii=True,
156+
traces_sample_rate=1.0,
157+
_experiments={
158+
"trace_lifecycle": "stream" if span_streaming else "static",
159+
},
160+
)
152161
app = SentryWsgiMiddleware(dogpark)
153162
client = Client(app)
154-
events = capture_events()
163+
164+
if span_streaming:
165+
items = capture_items("event", "span")
166+
else:
167+
events = capture_events()
155168

156169
with pytest.raises(ValueError):
157170
client.get("http://dogs.are.great/sit/stay/rollover/")
158171

159-
error_event, envelope = events
172+
sentry_sdk.flush()
173+
174+
if span_streaming:
175+
assert len(items) == 2
176+
assert items[0].type == "event"
177+
assert items[1].type == "span"
178+
179+
error_event = items[0].payload
180+
span_item = items[1].payload
181+
else:
182+
error_event, envelope = events
183+
184+
assert error_event["transaction"] == "generic WSGI request"
160185

161-
assert error_event["transaction"] == "generic WSGI request"
162186
assert error_event["contexts"]["trace"]["op"] == "http.server"
163187
assert error_event["exception"]["values"][0]["type"] == "ValueError"
164188
assert error_event["exception"]["values"][0]["mechanism"]["type"] == "wsgi"
@@ -168,15 +192,20 @@ def dogpark(environ, start_response):
168192
== "Fetch aborted. The ball was not returned."
169193
)
170194

171-
assert envelope["type"] == "transaction"
195+
if span_streaming:
196+
assert span_item["trace_id"] == error_event["contexts"]["trace"]["trace_id"]
197+
assert span_item["span_id"] == error_event["contexts"]["trace"]["span_id"]
198+
assert span_item["status"] == "error"
199+
else:
200+
assert envelope["type"] == "transaction"
172201

173-
# event trace context is a subset of envelope trace context
174-
assert envelope["contexts"]["trace"] == DictionaryContaining(
175-
error_event["contexts"]["trace"]
176-
)
177-
assert envelope["contexts"]["trace"]["status"] == "internal_error"
178-
assert envelope["transaction"] == error_event["transaction"]
179-
assert envelope["request"] == error_event["request"]
202+
# event trace context is a subset of envelope trace context
203+
assert envelope["contexts"]["trace"] == DictionaryContaining(
204+
error_event["contexts"]["trace"]
205+
)
206+
assert envelope["contexts"]["trace"]["status"] == "internal_error"
207+
assert envelope["transaction"] == error_event["transaction"]
208+
assert envelope["request"] == error_event["request"]
180209

181210

182211
def test_transaction_no_error(

0 commit comments

Comments
 (0)