diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index d871eac4ab..525ca4b5b5 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -132,6 +132,8 @@ def _get_request_attributes(asgi_scope: "Any") -> "dict[str, Any]": client = asgi_scope.get("client") if client and should_send_default_pii(): - attributes["client.address"] = _get_ip(asgi_scope) + ip = _get_ip(asgi_scope) + attributes["client.address"] = ip + attributes["user.ip_address"] = ip return attributes diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index eebd7eb4c3..8814a82858 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -13,7 +13,9 @@ ) from sentry_sdk.scope import should_send_default_pii, use_isolation_scope from sentry_sdk.sessions import track_session -from sentry_sdk.tracing import Transaction, TransactionSource +from sentry_sdk.traces import StreamedSpan, SegmentSource +from sentry_sdk.tracing import Span, TransactionSource +from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( ContextVar, capture_internal_exceptions, @@ -22,7 +24,18 @@ ) if TYPE_CHECKING: - from typing import Any, Callable, Dict, Iterator, Optional, Protocol, Tuple, TypeVar + from typing import ( + Any, + Callable, + ContextManager, + Dict, + Iterator, + Optional, + Protocol, + Tuple, + TypeVar, + Union, + ) from sentry_sdk._types import Event, EventProcessor from sentry_sdk.utils import ExcInfo @@ -42,6 +55,7 @@ def __call__( _wsgi_middleware_applied = ContextVar("sentry_wsgi_middleware_applied") +_DEFAULT_TRANSACTION_NAME = "generic WSGI request" def wsgi_decoding_dance(s: str, charset: str = "utf-8", errors: str = "replace") -> str: @@ -94,6 +108,9 @@ def __call__( if _wsgi_middleware_applied.get(False): return self.app(environ, start_response) + client = sentry_sdk.get_client() + span_streaming = has_span_streaming_enabled(client.options) + _wsgi_middleware_applied.set(True) try: with sentry_sdk.isolation_scope() as scope: @@ -108,34 +125,72 @@ def __call__( ) method = environ.get("REQUEST_METHOD", "").upper() - transaction = None + + span_ctx: "Optional[ContextManager[Union[Span, StreamedSpan, None]]]" = None if method in self.http_methods_to_capture: - transaction = continue_trace( - environ, - op=OP.HTTP_SERVER, - name="generic WSGI request", - source=TransactionSource.ROUTE, - origin=self.span_origin, - ) + if span_streaming: + sentry_sdk.traces.continue_trace( + dict(_get_headers(environ)) + ) + scope.set_custom_sampling_context({"wsgi_environ": environ}) + + span_ctx = sentry_sdk.traces.start_span( + name=_DEFAULT_TRANSACTION_NAME, + attributes={ + "sentry.span.source": SegmentSource.ROUTE, + "sentry.origin": self.span_origin, + "sentry.op": OP.HTTP_SERVER, + }, + ) + else: + transaction = continue_trace( + environ, + op=OP.HTTP_SERVER, + name=_DEFAULT_TRANSACTION_NAME, + source=TransactionSource.ROUTE, + origin=self.span_origin, + ) + + span_ctx = sentry_sdk.start_transaction( + transaction, + custom_sampling_context={"wsgi_environ": environ}, + ) + + span_ctx = span_ctx or nullcontext() + + with span_ctx as span: + if isinstance(span, StreamedSpan): + with capture_internal_exceptions(): + for attr, value in _get_request_attributes( + environ, self.use_x_forwarded_for + ).items(): + span.set_attribute(attr, value) - transaction_context = ( - sentry_sdk.start_transaction( - transaction, - custom_sampling_context={"wsgi_environ": environ}, - ) - if transaction is not None - else nullcontext() - ) - with transaction_context: try: response = self.app( environ, - partial( - _sentry_start_response, start_response, transaction - ), + partial(_sentry_start_response, start_response, span), ) except BaseException: reraise(*_capture_exception()) + finally: + if isinstance(span, StreamedSpan): + already_set = ( + span.name != _DEFAULT_TRANSACTION_NAME + and span.get_attributes().get("sentry.span.source") + in [ + SegmentSource.COMPONENT.value, + SegmentSource.ROUTE.value, + SegmentSource.CUSTOM.value, + ] + ) + if not already_set: + with capture_internal_exceptions(): + span.name = _DEFAULT_TRANSACTION_NAME + span.set_attribute( + "sentry.span.source", + SegmentSource.ROUTE.value, + ) finally: _wsgi_middleware_applied.set(False) @@ -167,15 +222,19 @@ def __call__( def _sentry_start_response( old_start_response: "StartResponse", - transaction: "Optional[Transaction]", + span: "Optional[Union[Span, StreamedSpan]]", status: str, response_headers: "WsgiResponseHeaders", exc_info: "Optional[WsgiExcInfo]" = None, ) -> "WsgiResponseIter": # type: ignore[type-var] with capture_internal_exceptions(): status_int = int(status.split(" ", 1)[0]) - if transaction is not None: - transaction.set_http_status(status_int) + if span is not None: + if isinstance(span, StreamedSpan): + span.status = "error" if status_int >= 400 else "ok" + span.set_attribute("http.response.status_code", status_int) + else: + span.set_http_status(status_int) if exc_info is None: # The Django Rest Framework WSGI test client, and likely other @@ -326,3 +385,50 @@ def event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": return event return event_processor + + +def _get_request_attributes( + environ: "Dict[str, str]", + use_x_forwarded_for: bool = False, +) -> "Dict[str, Any]": + """ + Return span attributes related to the HTTP request from the WSGI environ. + """ + attributes: "dict[str, Any]" = {} + + method = environ.get("REQUEST_METHOD") + if method: + attributes["http.request.method"] = method.upper() + + headers = _filter_headers(dict(_get_headers(environ)), use_annotated_value=False) + for header, value in headers.items(): + attributes[f"http.request.header.{header.lower()}"] = value + + query_string = environ.get("QUERY_STRING") + if query_string: + attributes["http.query"] = query_string + + attributes["url.full"] = get_request_url(environ, use_x_forwarded_for) + + url_scheme = environ.get("wsgi.url_scheme") + if url_scheme: + attributes["network.protocol.name"] = url_scheme + + server_name = environ.get("SERVER_NAME") + if server_name: + attributes["server.address"] = server_name + + server_port = environ.get("SERVER_PORT") + if server_port: + try: + attributes["server.port"] = int(server_port) + except ValueError: + pass + + if should_send_default_pii(): + client_ip = get_client_ip(environ) + if client_ip: + attributes["client.address"] = client_ip + attributes["user.ip_address"] = client_ip + + return attributes diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index e92c0bf7fc..750e602127 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -714,7 +714,7 @@ def get_active_propagation_context(self) -> "PropagationContext": def set_custom_sampling_context( self, custom_sampling_context: "dict[str, Any]" ) -> None: - self.get_active_propagation_context()._set_custom_sampling_context( + self.get_current_scope().get_active_propagation_context()._set_custom_sampling_context( custom_sampling_context ) diff --git a/tests/integrations/wsgi/test_wsgi.py b/tests/integrations/wsgi/test_wsgi.py index 48b3b4349b..a95a1d63fa 100644 --- a/tests/integrations/wsgi/test_wsgi.py +++ b/tests/integrations/wsgi/test_wsgi.py @@ -139,26 +139,50 @@ def test_keyboard_interrupt_is_captured(sentry_init, capture_events): assert event["level"] == "error" +@pytest.mark.parametrize("span_streaming", [True, False]) def test_transaction_with_error( sentry_init, crashing_app, capture_events, + capture_items, DictionaryContaining, # noqa:N803 + span_streaming, ): def dogpark(environ, start_response): raise ValueError("Fetch aborted. The ball was not returned.") - sentry_init(send_default_pii=True, traces_sample_rate=1.0) + sentry_init( + send_default_pii=True, + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(dogpark) client = Client(app) - events = capture_events() + + if span_streaming: + items = capture_items("event", "span") + else: + events = capture_events() with pytest.raises(ValueError): client.get("http://dogs.are.great/sit/stay/rollover/") - error_event, envelope = events + sentry_sdk.flush() + + if span_streaming: + assert len(items) == 2 + assert items[0].type == "event" + assert items[1].type == "span" + + error_event = items[0].payload + span_item = items[1].payload + else: + error_event, envelope = events + + assert error_event["transaction"] == "generic WSGI request" - assert error_event["transaction"] == "generic WSGI request" assert error_event["contexts"]["trace"]["op"] == "http.server" assert error_event["exception"]["values"][0]["type"] == "ValueError" assert error_event["exception"]["values"][0]["mechanism"]["type"] == "wsgi" @@ -168,75 +192,145 @@ def dogpark(environ, start_response): == "Fetch aborted. The ball was not returned." ) - assert envelope["type"] == "transaction" + if span_streaming: + assert span_item["trace_id"] == error_event["contexts"]["trace"]["trace_id"] + assert span_item["span_id"] == error_event["contexts"]["trace"]["span_id"] + assert span_item["status"] == "error" + else: + assert envelope["type"] == "transaction" - # event trace context is a subset of envelope trace context - assert envelope["contexts"]["trace"] == DictionaryContaining( - error_event["contexts"]["trace"] - ) - assert envelope["contexts"]["trace"]["status"] == "internal_error" - assert envelope["transaction"] == error_event["transaction"] - assert envelope["request"] == error_event["request"] + # event trace context is a subset of envelope trace context + assert envelope["contexts"]["trace"] == DictionaryContaining( + error_event["contexts"]["trace"] + ) + assert envelope["contexts"]["trace"]["status"] == "internal_error" + assert envelope["transaction"] == error_event["transaction"] + assert envelope["request"] == error_event["request"] +@pytest.mark.parametrize("span_streaming", [True, False]) def test_transaction_no_error( sentry_init, capture_events, + capture_items, DictionaryContaining, # noqa:N803 + span_streaming, ): def dogpark(environ, start_response): start_response("200 OK", []) return ["Go get the ball! Good dog!"] - sentry_init(send_default_pii=True, traces_sample_rate=1.0) + sentry_init( + send_default_pii=True, + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(dogpark) client = Client(app) - events = capture_events() + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() client.get("/dogs/are/great/") - envelope = events[0] + sentry_sdk.flush() - assert envelope["type"] == "transaction" - assert envelope["transaction"] == "generic WSGI request" - assert envelope["contexts"]["trace"]["op"] == "http.server" - assert envelope["request"] == DictionaryContaining( - {"method": "GET", "url": "http://localhost/dogs/are/great/"} - ) + if span_streaming: + assert len(items) == 1 + span = items[0].payload + + assert span["is_segment"] is True + assert span["name"] == "generic WSGI request" + assert span["attributes"]["sentry.op"] == "http.server" + assert span["attributes"]["sentry.span.source"] == "route" + assert span["attributes"]["http.request.method"] == "GET" + assert span["attributes"]["url.full"] == "http://localhost/dogs/are/great/" + assert span["attributes"]["http.response.status_code"] == 200 + assert span["status"] == "ok" + else: + envelope = events[0] + + assert envelope["type"] == "transaction" + assert envelope["transaction"] == "generic WSGI request" + assert envelope["contexts"]["trace"]["op"] == "http.server" + assert envelope["request"] == DictionaryContaining( + {"method": "GET", "url": "http://localhost/dogs/are/great/"} + ) +@pytest.mark.parametrize("span_streaming", [True, False]) def test_has_trace_if_performance_enabled( sentry_init, capture_events, + capture_items, + span_streaming, ): def dogpark(environ, start_response): capture_message("Attempting to fetch the ball") raise ValueError("Fetch aborted. The ball was not returned.") - sentry_init(traces_sample_rate=1.0) + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(dogpark) client = Client(app) - events = capture_events() + + if span_streaming: + items = capture_items("event", "span") + else: + events = capture_events() with pytest.raises(ValueError): client.get("http://dogs.are.great/sit/stay/rollover/") - msg_event, error_event, transaction_event = events + sentry_sdk.flush() - assert msg_event["contexts"]["trace"] - assert "trace_id" in msg_event["contexts"]["trace"] + if span_streaming: + msg_event, error_event, span_item = items - assert error_event["contexts"]["trace"] - assert "trace_id" in error_event["contexts"]["trace"] + assert msg_event.type == "event" + msg_event = msg_event.payload + assert msg_event["contexts"]["trace"] + assert "trace_id" in msg_event["contexts"]["trace"] - assert transaction_event["contexts"]["trace"] - assert "trace_id" in transaction_event["contexts"]["trace"] + assert error_event.type == "event" + error_event = error_event.payload + assert error_event["contexts"]["trace"] + assert "trace_id" in error_event["contexts"]["trace"] - assert ( - msg_event["contexts"]["trace"]["trace_id"] - == error_event["contexts"]["trace"]["trace_id"] - == transaction_event["contexts"]["trace"]["trace_id"] - ) + assert span_item.type == "span" + span_item = span_item.payload + assert span_item["trace_id"] is not None + + assert ( + msg_event["contexts"]["trace"]["trace_id"] + == error_event["contexts"]["trace"]["trace_id"] + == span_item["trace_id"] + ) + else: + msg_event, error_event, transaction_event = events + + assert msg_event["contexts"]["trace"] + assert "trace_id" in msg_event["contexts"]["trace"] + + assert error_event["contexts"]["trace"] + assert "trace_id" in error_event["contexts"]["trace"] + + assert transaction_event["contexts"]["trace"] + assert "trace_id" in transaction_event["contexts"]["trace"] + + assert ( + msg_event["contexts"]["trace"]["trace_id"] + == error_event["contexts"]["trace"]["trace_id"] + == transaction_event["contexts"]["trace"]["trace_id"] + ) def test_has_trace_if_performance_disabled( @@ -264,18 +358,30 @@ def dogpark(environ, start_response): assert "trace_id" in error_event["contexts"]["trace"] +@pytest.mark.parametrize("span_streaming", [True, False]) def test_trace_from_headers_if_performance_enabled( sentry_init, capture_events, + capture_items, + span_streaming, ): def dogpark(environ, start_response): capture_message("Attempting to fetch the ball") raise ValueError("Fetch aborted. The ball was not returned.") - sentry_init(traces_sample_rate=1.0) + sentry_init( + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(dogpark) client = Client(app) - events = capture_events() + + if span_streaming: + items = capture_items("event", "span") + else: + events = capture_events() trace_id = "582b43a4192642f0b136d5159a501701" sentry_trace_header = "{}-{}-{}".format(trace_id, "6e8f22c393e68f19", 1) @@ -286,20 +392,29 @@ def dogpark(environ, start_response): headers={"sentry-trace": sentry_trace_header}, ) - msg_event, error_event, transaction_event = events + sentry_sdk.flush() - assert msg_event["contexts"]["trace"] - assert "trace_id" in msg_event["contexts"]["trace"] + if span_streaming: + msg_event, error_event, span_item = items - assert error_event["contexts"]["trace"] - assert "trace_id" in error_event["contexts"]["trace"] + assert msg_event.payload["contexts"]["trace"]["trace_id"] == trace_id + assert error_event.payload["contexts"]["trace"]["trace_id"] == trace_id + assert span_item.payload["trace_id"] == trace_id + else: + msg_event, error_event, transaction_event = events - assert transaction_event["contexts"]["trace"] - assert "trace_id" in transaction_event["contexts"]["trace"] + assert msg_event["contexts"]["trace"] + assert "trace_id" in msg_event["contexts"]["trace"] - assert msg_event["contexts"]["trace"]["trace_id"] == trace_id - assert error_event["contexts"]["trace"]["trace_id"] == trace_id - assert transaction_event["contexts"]["trace"]["trace_id"] == trace_id + assert error_event["contexts"]["trace"] + assert "trace_id" in error_event["contexts"]["trace"] + + assert transaction_event["contexts"]["trace"] + assert "trace_id" in transaction_event["contexts"]["trace"] + + assert msg_event["contexts"]["trace"]["trace_id"] == trace_id + assert error_event["contexts"]["trace"]["trace_id"] == trace_id + assert transaction_event["contexts"]["trace"]["trace_id"] == trace_id def test_trace_from_headers_if_performance_disabled( @@ -335,37 +450,65 @@ def dogpark(environ, start_response): assert error_event["contexts"]["trace"]["trace_id"] == trace_id +@pytest.mark.parametrize("span_streaming", [True, False]) def test_traces_sampler_gets_correct_values_in_sampling_context( sentry_init, DictionaryContaining, # noqa:N803 + span_streaming, ): def app(environ, start_response): start_response("200 OK", []) return ["Go get the ball! Good dog!"] traces_sampler = mock.Mock(return_value=True) - sentry_init(send_default_pii=True, traces_sampler=traces_sampler) + sentry_init( + send_default_pii=True, + traces_sampler=traces_sampler, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(app) client = Client(app) client.get("/dogs/are/great/") - traces_sampler.assert_any_call( - DictionaryContaining( - { - "wsgi_environ": DictionaryContaining( - { - "PATH_INFO": "/dogs/are/great/", - "REQUEST_METHOD": "GET", - }, - ), - } + if span_streaming: + traces_sampler.assert_any_call( + DictionaryContaining( + { + "span_context": DictionaryContaining( + { + "name": "generic WSGI request", + }, + ), + "wsgi_environ": DictionaryContaining( + { + "PATH_INFO": "/dogs/are/great/", + "REQUEST_METHOD": "GET", + }, + ), + } + ) + ) + else: + traces_sampler.assert_any_call( + DictionaryContaining( + { + "wsgi_environ": DictionaryContaining( + { + "PATH_INFO": "/dogs/are/great/", + "REQUEST_METHOD": "GET", + }, + ), + } + ) ) - ) +@pytest.mark.parametrize("span_streaming", [True, False]) def test_session_mode_defaults_to_request_mode_in_wsgi_handler( - capture_envelopes, sentry_init + capture_envelopes, sentry_init, span_streaming ): """ Test that ensures that even though the default `session_mode` for @@ -378,7 +521,13 @@ def app(environ, start_response): return ["Go get the ball! Good dog!"] traces_sampler = mock.Mock(return_value=True) - sentry_init(send_default_pii=True, traces_sampler=traces_sampler) + sentry_init( + send_default_pii=True, + traces_sampler=traces_sampler, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(app) envelopes = capture_envelopes() @@ -388,7 +537,11 @@ def app(environ, start_response): sentry_sdk.flush() - sess = envelopes[1] + session_envelopes = [ + e for e in envelopes if any(item.type == "sessions" for item in e.items) + ] + assert len(session_envelopes) == 1 + sess = session_envelopes[0] assert len(sess.items) == 1 sess_event = sess.items[0].payload.json @@ -397,7 +550,10 @@ def app(environ, start_response): assert aggregates[0]["exited"] == 1 -def test_auto_session_tracking_with_aggregates(sentry_init, capture_envelopes): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_auto_session_tracking_with_aggregates( + sentry_init, capture_envelopes, span_streaming +): """ Test for correct session aggregates in auto session tracking. """ @@ -410,7 +566,13 @@ def sample_app(environ, start_response): return ["Go get the ball! Good dog!"] traces_sampler = mock.Mock(return_value=True) - sentry_init(send_default_pii=True, traces_sampler=traces_sampler) + sentry_init( + send_default_pii=True, + traces_sampler=traces_sampler, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(sample_app) envelopes = capture_envelopes() assert len(envelopes) == 0 @@ -427,14 +589,21 @@ def sample_app(environ, start_response): count_item_types = Counter() for envelope in envelopes: - count_item_types[envelope.items[0].type] += 1 + for item in envelope.items: + count_item_types[item.type] += 1 - assert count_item_types["transaction"] == 3 + if span_streaming: + assert count_item_types["span"] == 3 + else: + assert count_item_types["transaction"] == 3 assert count_item_types["event"] == 1 assert count_item_types["sessions"] == 1 - assert len(envelopes) == 5 - session_aggregates = envelopes[-1].items[0].payload.json["aggregates"] + session_envelopes = [ + e for e in envelopes if any(item.type == "sessions" for item in e.items) + ] + assert len(session_envelopes) == 1 + session_aggregates = session_envelopes[0].items[0].payload.json["aggregates"] assert session_aggregates[0]["exited"] == 2 assert session_aggregates[0]["crashed"] == 1 assert len(session_aggregates) == 1 @@ -467,43 +636,73 @@ def test_app(environ, start_response): assert len(profiles) == 1 -def test_span_origin_manual(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_span_origin_manual(sentry_init, capture_events, capture_items, span_streaming): def dogpark(environ, start_response): start_response("200 OK", []) return ["Go get the ball! Good dog!"] - sentry_init(send_default_pii=True, traces_sample_rate=1.0) + sentry_init( + send_default_pii=True, + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware(dogpark) - events = capture_events() + if span_streaming: + items = capture_items("span") + else: + events = capture_events() client = Client(app) client.get("/dogs/are/great/") - (event,) = events + sentry_sdk.flush() - assert event["contexts"]["trace"]["origin"] == "manual" + if span_streaming: + assert len(items) == 1 + assert items[0].payload["attributes"]["sentry.origin"] == "manual" + else: + (event,) = events + assert event["contexts"]["trace"]["origin"] == "manual" -def test_span_origin_custom(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_span_origin_custom(sentry_init, capture_events, capture_items, span_streaming): def dogpark(environ, start_response): start_response("200 OK", []) return ["Go get the ball! Good dog!"] - sentry_init(send_default_pii=True, traces_sample_rate=1.0) + sentry_init( + send_default_pii=True, + traces_sample_rate=1.0, + _experiments={ + "trace_lifecycle": "stream" if span_streaming else "static", + }, + ) app = SentryWsgiMiddleware( dogpark, span_origin="auto.dogpark.deluxe", ) - events = capture_events() + if span_streaming: + items = capture_items("span") + else: + events = capture_events() client = Client(app) client.get("/dogs/are/great/") - (event,) = events + sentry_sdk.flush() - assert event["contexts"]["trace"]["origin"] == "auto.dogpark.deluxe" + if span_streaming: + assert len(items) == 1 + assert items[0].payload["attributes"]["sentry.origin"] == "auto.dogpark.deluxe" + else: + (event,) = events + assert event["contexts"]["trace"]["origin"] == "auto.dogpark.deluxe" @pytest.mark.parametrize(