From a3f7747df1bd4fcf07f71f390cc92fe7eec77edc Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 10:02:55 +0200 Subject: [PATCH 01/26] feat(stdlib): Support span streaming --- sentry_sdk/integrations/stdlib.py | 78 ++++++++++++++++------ tests/integrations/stdlib/test_httplib.py | 80 ++++++++++++----------- 2 files changed, 102 insertions(+), 56 deletions(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index e3120a3b32..b58663afaa 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -8,10 +8,13 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor +from sentry_sdk.tracing import Span +from sentry_sdk.traces import StreamedSpan from sentry_sdk.tracing_utils import ( EnvironHeaders, should_propagate_trace, add_http_request_source, + has_span_streaming_enabled, ) from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, @@ -31,6 +34,7 @@ from typing import Dict from typing import Optional from typing import List + from typing import Union from sentry_sdk._types import Event, Hint @@ -99,22 +103,38 @@ def putrequest( with capture_internal_exceptions(): parsed_url = parse_url(real_url, sanitize=False) - span = sentry_sdk.start_span( - op=OP.HTTP_CLIENT, - name="%s %s" - % (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE), - origin="auto.http.stdlib.httplib", - ) - span.set_data(SPANDATA.HTTP_METHOD, method) + span_streaming = has_span_streaming_enabled(client.options) + span: "Union[Span, StreamedSpan]" + if span_streaming: + span = sentry_sdk.traces.start_span( + name="%s %s" + % (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE), + attributes={ + "sentry.origin": "auto.http.stdlib.httplib", + "sentry.op": OP.HTTP_CLIENT, + }, + ) + set_on_span = span.set_attribute + + else: + span = sentry_sdk.start_span( + op=OP.HTTP_CLIENT, + name="%s %s" + % (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE), + origin="auto.http.stdlib.httplib", + ) + set_on_span = span.set_data + + set_on_span(SPANDATA.HTTP_METHOD, method) if parsed_url is not None: - span.set_data("url", parsed_url.url) - span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) - span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + set_on_span("url", parsed_url.url) + set_on_span(SPANDATA.HTTP_QUERY, parsed_url.query) + set_on_span(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) # for proxies, these point to the proxy host/port if tunnel_host: - span.set_data(SPANDATA.NETWORK_PEER_ADDRESS, self.host) - span.set_data(SPANDATA.NETWORK_PEER_PORT, self.port) + set_on_span(SPANDATA.NETWORK_PEER_ADDRESS, self.host) + set_on_span(SPANDATA.NETWORK_PEER_PORT, self.port) rv = real_putrequest(self, method, url, *args, **kwargs) @@ -139,14 +159,23 @@ def putrequest( def getresponse(self: "HTTPConnection", *args: "Any", **kwargs: "Any") -> "Any": span = getattr(self, "_sentrysdk_span", None) + print("span is here") + if span is None: return real_getresponse(self, *args, **kwargs) try: rv = real_getresponse(self, *args, **kwargs) - span.set_http_status(int(rv.status)) - span.set_data("reason", rv.reason) + if isinstance(span, StreamedSpan): + span.set_attribute("reason", rv.reason) + + status_code = int(rv.status) + span.status = "error" if status_code >= 400 else "ok" + span.set_attribute("http.response.status_code", status_code) + else: + span.set_http_status(int(rv.status)) + span.set_data("reason", rv.reason) finally: span.finish() @@ -226,11 +255,22 @@ def sentry_patched_popen_init( env = None - with sentry_sdk.start_span( - op=OP.SUBPROCESS, - name=description, - origin="auto.subprocess.stdlib.subprocess", - ) as span: + span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) + span: "Union[Span, StreamedSpan]" + if span_streaming: + span = sentry_sdk.start_span( + op=OP.SUBPROCESS, + name=description, + origin="auto.subprocess.stdlib.subprocess", + ) + else: + span = sentry_sdk.start_span( + op=OP.SUBPROCESS, + name=description, + origin="auto.subprocess.stdlib.subprocess", + ) + + with span: for k, v in sentry_sdk.get_current_scope().iter_trace_propagation_headers( span=span ): diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index cdbf6cd68c..7f22fe47de 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -202,7 +202,7 @@ def test_httplib_misuse(sentry_init, capture_events, request): ) -def test_outgoing_trace_headers(sentry_init, monkeypatch): +def test_outgoing_trace_headers(sentry_init, capture_events, monkeypatch): # HTTPSConnection.send is passed a string containing (among other things) # the headers on the request. Mock it so we can check the headers, and also # so it doesn't try to actually talk to the internet. @@ -210,6 +210,7 @@ def test_outgoing_trace_headers(sentry_init, monkeypatch): monkeypatch.setattr(HTTPSConnection, "send", mock_send) sentry_init(traces_sample_rate=1.0) + events = capture_events() headers = { "sentry-trace": "771a43a4192642f0b136d5159a501700-1234567890abcdef-1", @@ -237,26 +238,27 @@ def test_outgoing_trace_headers(sentry_init, monkeypatch): key, val = line.split(": ") request_headers[key] = val - request_span = transaction._span_recorder.spans[-1] - expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( - trace_id=transaction.trace_id, - parent_span_id=request_span.span_id, - sampled=1, - ) - assert request_headers["sentry-trace"] == expected_sentry_trace - - expected_outgoing_baggage = ( - "sentry-trace_id=771a43a4192642f0b136d5159a501700," - "sentry-public_key=49d0f7386ad645858ae85020e393bef3," - "sentry-sample_rate=1.0," - "sentry-user_id=Am%C3%A9lie," - "sentry-sample_rand=0.132521102938283" - ) + (event,) = events + request_span = event["spans"][-1] + expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=transaction.trace_id, + parent_span_id=request_span.span_id, + sampled=1, + ) + assert request_headers["sentry-trace"] == expected_sentry_trace + + expected_outgoing_baggage = ( + "sentry-trace_id=771a43a4192642f0b136d5159a501700," + "sentry-public_key=49d0f7386ad645858ae85020e393bef3," + "sentry-sample_rate=1.0," + "sentry-user_id=Am%C3%A9lie," + "sentry-sample_rand=0.132521102938283" + ) - assert request_headers["baggage"] == expected_outgoing_baggage + assert request_headers["baggage"] == expected_outgoing_baggage -def test_outgoing_trace_headers_head_sdk(sentry_init, monkeypatch): +def test_outgoing_trace_headers_head_sdk(sentry_init, capture_events, monkeypatch): # HTTPSConnection.send is passed a string containing (among other things) # the headers on the request. Mock it so we can check the headers, and also # so it doesn't try to actually talk to the internet. @@ -264,11 +266,14 @@ def test_outgoing_trace_headers_head_sdk(sentry_init, monkeypatch): monkeypatch.setattr(HTTPSConnection, "send", mock_send) sentry_init(traces_sample_rate=0.5, release="foo") + events = capture_events() + with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=250000): transaction = continue_trace({}) with start_transaction(transaction=transaction, name="Head SDK tx") as transaction: - HTTPSConnection("www.squirrelchasers.com").request("GET", "/top-chasers") + connection = HTTPSConnection("www.squirrelchasers.com") + connection.request("GET", "/top-chasers") (request_str,) = mock_send.call_args[0] request_headers = {} @@ -277,24 +282,25 @@ def test_outgoing_trace_headers_head_sdk(sentry_init, monkeypatch): key, val = line.split(": ") request_headers[key] = val - request_span = transaction._span_recorder.spans[-1] - expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( - trace_id=transaction.trace_id, - parent_span_id=request_span.span_id, - sampled=1, - ) - assert request_headers["sentry-trace"] == expected_sentry_trace - - expected_outgoing_baggage = ( - "sentry-trace_id=%s," - "sentry-sample_rand=0.250000," - "sentry-environment=production," - "sentry-release=foo," - "sentry-sample_rate=0.5," - "sentry-sampled=%s" - ) % (transaction.trace_id, "true" if transaction.sampled else "false") - - assert request_headers["baggage"] == expected_outgoing_baggage + (event,) = events + request_span = event["spans"][0] + expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=transaction.trace_id, + parent_span_id=request_span.span_id, + sampled=1, + ) + assert request_headers["sentry-trace"] == expected_sentry_trace + + expected_outgoing_baggage = ( + "sentry-trace_id=%s," + "sentry-sample_rand=0.250000," + "sentry-environment=production," + "sentry-release=foo," + "sentry-sample_rate=0.5," + "sentry-sampled=%s" + ) % (transaction.trace_id, "true" if transaction.sampled else "false") + + assert request_headers["baggage"] == expected_outgoing_baggage @pytest.mark.parametrize( From fb549ba0884a1e644638633900b0d7449d60f2f3 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 10:46:47 +0200 Subject: [PATCH 02/26] test(stdlib): Remove mocks in outgoing trace header tests --- tests/integrations/stdlib/test_httplib.py | 132 ++++++++++++---------- 1 file changed, 72 insertions(+), 60 deletions(-) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index cdbf6cd68c..5890962ca7 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -202,14 +202,23 @@ def test_httplib_misuse(sentry_init, capture_events, request): ) -def test_outgoing_trace_headers(sentry_init, monkeypatch): - # HTTPSConnection.send is passed a string containing (among other things) - # the headers on the request. Mock it so we can check the headers, and also - # so it doesn't try to actually talk to the internet. - mock_send = mock.Mock() - monkeypatch.setattr(HTTPSConnection, "send", mock_send) +def test_outgoing_trace_headers(sentry_init, capture_events): + original_send = HTTPConnection.send + + request_headers = {} + + class HttpConnectionRecordingRequestHeaders(HTTPConnection): + def send(self, *args, **kwargs) -> None: + request_str = args[0] + for line in request_str.decode("utf-8").split("\r\n")[1:]: + if line: + key, val = line.split(": ") + request_headers[key] = val + + original_send(self, *args, **kwargs) sentry_init(traces_sample_rate=1.0) + events = capture_events() headers = { "sentry-trace": "771a43a4192642f0b136d5159a501700-1234567890abcdef-1", @@ -228,73 +237,76 @@ def test_outgoing_trace_headers(sentry_init, monkeypatch): op="greeting.sniff", trace_id="12312012123120121231201212312012", ) as transaction: - HTTPSConnection("www.squirrelchasers.com").request("GET", "/top-chasers") + connection = HttpConnectionRecordingRequestHeaders("localhost", port=PORT) + connection.request("GET", "/top-chasers") + connection.getresponse() - (request_str,) = mock_send.call_args[0] - request_headers = {} - for line in request_str.decode("utf-8").split("\r\n")[1:]: - if line: - key, val = line.split(": ") - request_headers[key] = val + (event,) = events + request_span = event["spans"][-1] + expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=event["contexts"]["trace"]["trace_id"], + parent_span_id=request_span["span_id"], + sampled=1, + ) + assert request_headers["sentry-trace"] == expected_sentry_trace + + expected_outgoing_baggage = ( + "sentry-trace_id=771a43a4192642f0b136d5159a501700," + "sentry-public_key=49d0f7386ad645858ae85020e393bef3," + "sentry-sample_rate=1.0," + "sentry-user_id=Am%C3%A9lie," + "sentry-sample_rand=0.132521102938283" + ) - request_span = transaction._span_recorder.spans[-1] - expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( - trace_id=transaction.trace_id, - parent_span_id=request_span.span_id, - sampled=1, - ) - assert request_headers["sentry-trace"] == expected_sentry_trace - - expected_outgoing_baggage = ( - "sentry-trace_id=771a43a4192642f0b136d5159a501700," - "sentry-public_key=49d0f7386ad645858ae85020e393bef3," - "sentry-sample_rate=1.0," - "sentry-user_id=Am%C3%A9lie," - "sentry-sample_rand=0.132521102938283" - ) + assert request_headers["baggage"] == expected_outgoing_baggage - assert request_headers["baggage"] == expected_outgoing_baggage +def test_outgoing_trace_headers_head_sdk(sentry_init, capture_events): + original_send = HTTPConnection.send -def test_outgoing_trace_headers_head_sdk(sentry_init, monkeypatch): - # HTTPSConnection.send is passed a string containing (among other things) - # the headers on the request. Mock it so we can check the headers, and also - # so it doesn't try to actually talk to the internet. - mock_send = mock.Mock() - monkeypatch.setattr(HTTPSConnection, "send", mock_send) + request_headers = {} + + class HttpConnectionRecordingRequestHeaders(HTTPConnection): + def send(self, *args, **kwargs) -> None: + request_str = args[0] + for line in request_str.decode("utf-8").split("\r\n")[1:]: + if line: + key, val = line.split(": ") + request_headers[key] = val + + original_send(self, *args, **kwargs) sentry_init(traces_sample_rate=0.5, release="foo") + events = capture_events() + with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=250000): transaction = continue_trace({}) with start_transaction(transaction=transaction, name="Head SDK tx") as transaction: - HTTPSConnection("www.squirrelchasers.com").request("GET", "/top-chasers") + connection = HttpConnectionRecordingRequestHeaders("localhost", port=PORT) + connection.request("GET", "/top-chasers") + connection.getresponse() - (request_str,) = mock_send.call_args[0] - request_headers = {} - for line in request_str.decode("utf-8").split("\r\n")[1:]: - if line: - key, val = line.split(": ") - request_headers[key] = val + (event,) = events + request_span = event["spans"][-1] + expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=event["contexts"]["trace"]["trace_id"], + parent_span_id=request_span["span_id"], + sampled=1, + ) - request_span = transaction._span_recorder.spans[-1] - expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( - trace_id=transaction.trace_id, - parent_span_id=request_span.span_id, - sampled=1, - ) - assert request_headers["sentry-trace"] == expected_sentry_trace - - expected_outgoing_baggage = ( - "sentry-trace_id=%s," - "sentry-sample_rand=0.250000," - "sentry-environment=production," - "sentry-release=foo," - "sentry-sample_rate=0.5," - "sentry-sampled=%s" - ) % (transaction.trace_id, "true" if transaction.sampled else "false") - - assert request_headers["baggage"] == expected_outgoing_baggage + assert request_headers["sentry-trace"] == expected_sentry_trace + + expected_outgoing_baggage = ( + "sentry-trace_id=%s," + "sentry-sample_rand=0.250000," + "sentry-environment=production," + "sentry-release=foo," + "sentry-sample_rate=0.5," + "sentry-sampled=%s" + ) % (transaction.trace_id, "true" if transaction.sampled else "false") + + assert request_headers["baggage"] == expected_outgoing_baggage @pytest.mark.parametrize( From 694114ff1b3e78db54560cb909ed20e1b6571a81 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 11:37:42 +0200 Subject: [PATCH 03/26] wip tests --- tests/integrations/stdlib/test_httplib.py | 523 +++++++++------------- 1 file changed, 219 insertions(+), 304 deletions(-) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 5890962ca7..b34a8aa390 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -4,17 +4,14 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from socket import SocketIO from threading import Thread -from urllib.error import HTTPError -from urllib.request import urlopen from unittest import mock import pytest -from sentry_sdk import capture_message, start_transaction, continue_trace +import sentry_sdk from sentry_sdk.consts import MATCH_ALL, SPANDATA -from sentry_sdk.integrations.stdlib import StdlibIntegration -from tests.conftest import ApproxDict, create_mock_http_server, get_free_port +from tests.conftest import create_mock_http_server, get_free_port PORT = create_mock_http_server() @@ -43,166 +40,22 @@ def create_mock_proxy_server(): PROXY_PORT = create_mock_proxy_server() -def test_crumb_capture(sentry_init, capture_events): - sentry_init(integrations=[StdlibIntegration()]) - events = capture_events() - - url = "http://localhost:{}/some/random/url".format(PORT) - urlopen(url) - - capture_message("Testing!") - - (event,) = events - (crumb,) = event["breadcrumbs"]["values"] - - assert crumb["type"] == "http" - assert crumb["category"] == "httplib" - assert crumb["data"] == ApproxDict( - { - "url": url, - SPANDATA.HTTP_METHOD: "GET", - SPANDATA.HTTP_STATUS_CODE: 200, - "reason": "OK", - SPANDATA.HTTP_FRAGMENT: "", - SPANDATA.HTTP_QUERY: "", - } - ) - - -@pytest.mark.parametrize( - "status_code,level", - [ - (200, None), - (301, None), - (403, "warning"), - (405, "warning"), - (500, "error"), - ], -) -def test_crumb_capture_client_error(sentry_init, capture_events, status_code, level): - sentry_init(integrations=[StdlibIntegration()]) - events = capture_events() - - url = f"http://localhost:{PORT}/status/{status_code}" # noqa:E231 - try: - urlopen(url) - except HTTPError: - pass - - capture_message("Testing!") - - (event,) = events - (crumb,) = event["breadcrumbs"]["values"] - - assert crumb["type"] == "http" - assert crumb["category"] == "httplib" - - if level is None: - assert "level" not in crumb - else: - assert crumb["level"] == level - - assert crumb["data"] == ApproxDict( - { - "url": url, - SPANDATA.HTTP_METHOD: "GET", - SPANDATA.HTTP_STATUS_CODE: status_code, - SPANDATA.HTTP_FRAGMENT: "", - SPANDATA.HTTP_QUERY: "", - } - ) - - -def test_crumb_capture_hint(sentry_init, capture_events): - def before_breadcrumb(crumb, hint): - crumb["data"]["extra"] = "foo" - return crumb - - sentry_init(integrations=[StdlibIntegration()], before_breadcrumb=before_breadcrumb) - events = capture_events() - - url = "http://localhost:{}/some/random/url".format(PORT) - urlopen(url) - - capture_message("Testing!") - - (event,) = events - (crumb,) = event["breadcrumbs"]["values"] - assert crumb["type"] == "http" - assert crumb["category"] == "httplib" - assert crumb["data"] == ApproxDict( - { - "url": url, - SPANDATA.HTTP_METHOD: "GET", - SPANDATA.HTTP_STATUS_CODE: 200, - "reason": "OK", - "extra": "foo", - SPANDATA.HTTP_FRAGMENT: "", - SPANDATA.HTTP_QUERY: "", - } - ) - - -def test_empty_realurl(sentry_init): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_empty_realurl(sentry_init, span_streaming): """ Ensure that after using sentry_sdk.init you can putrequest a None url. """ - sentry_init(dsn="") - HTTPConnection("localhost", port=PORT).putrequest("POST", None) - - -def test_httplib_misuse(sentry_init, capture_events, request): - """HTTPConnection.getresponse must be called after every call to - HTTPConnection.request. However, if somebody does not abide by - this contract, we still should handle this gracefully and not - send mixed breadcrumbs. - - Test whether our breadcrumbs are coherent when somebody uses HTTPConnection - wrongly. - """ - - sentry_init() - events = capture_events() - - conn = HTTPConnection("localhost", PORT) - - # make sure we release the resource, even if the test fails - request.addfinalizer(conn.close) - - conn.request("GET", "/200") - - with pytest.raises(Exception): # noqa: B017 - # This raises an exception, because we didn't call `getresponse` for - # the previous request yet. - # - # This call should not affect our breadcrumb. - conn.request("POST", "/200") - - response = conn.getresponse() - assert response._method == "GET" - - capture_message("Testing!") - - (event,) = events - (crumb,) = event["breadcrumbs"]["values"] - - assert crumb["type"] == "http" - assert crumb["category"] == "httplib" - assert crumb["data"] == ApproxDict( - { - "url": "http://localhost:{}/200".format(PORT), - SPANDATA.HTTP_METHOD: "GET", - SPANDATA.HTTP_STATUS_CODE: 200, - "reason": "OK", - SPANDATA.HTTP_FRAGMENT: "", - SPANDATA.HTTP_QUERY: "", - } + sentry_init( + dsn="", + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) + HTTPConnection("localhost", port=PORT).putrequest("POST", None) -def test_outgoing_trace_headers(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_outgoing_trace_headers(sentry_init, capture_items, span_streaming): original_send = HTTPConnection.send request_headers = {} @@ -217,8 +70,11 @@ def send(self, *args, **kwargs) -> None: original_send(self, *args, **kwargs) - sentry_init(traces_sample_rate=1.0) - events = capture_events() + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) + items = capture_items("event", "transaction", "span") headers = { "sentry-trace": "771a43a4192642f0b136d5159a501700-1234567890abcdef-1", @@ -229,22 +85,22 @@ def send(self, *args, **kwargs) -> None: ), } - transaction = continue_trace(headers) + sentry_sdk.traces.continue_trace(headers) - with start_transaction( - transaction=transaction, + with sentry_sdk.traces.start_span( name="/interactions/other-dogs/new-dog", - op="greeting.sniff", - trace_id="12312012123120121231201212312012", - ) as transaction: + attributes={ + "sentry.op": "greeting.sniff", + }, + ): connection = HttpConnectionRecordingRequestHeaders("localhost", port=PORT) connection.request("GET", "/top-chasers") connection.getresponse() - (event,) = events - request_span = event["spans"][-1] + sentry_sdk.flush() + request_span = [item.payload for item in items if item.type == "span"][0] expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( - trace_id=event["contexts"]["trace"]["trace_id"], + trace_id=request_span["trace_id"], parent_span_id=request_span["span_id"], sampled=1, ) @@ -261,7 +117,8 @@ def send(self, *args, **kwargs) -> None: assert request_headers["baggage"] == expected_outgoing_baggage -def test_outgoing_trace_headers_head_sdk(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_outgoing_trace_headers_head_sdk(sentry_init, capture_items, span_streaming): original_send = HTTPConnection.send request_headers = {} @@ -276,21 +133,25 @@ def send(self, *args, **kwargs) -> None: original_send(self, *args, **kwargs) - sentry_init(traces_sample_rate=0.5, release="foo") - events = capture_events() + sentry_init( + traces_sample_rate=0.5, + release="foo", + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) + items = capture_items("event", "transaction", "span") with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=250000): - transaction = continue_trace({}) + sentry_sdk.traces.continue_trace({}) - with start_transaction(transaction=transaction, name="Head SDK tx") as transaction: + with sentry_sdk.traces.start_span(name="Head SDK tx"): connection = HttpConnectionRecordingRequestHeaders("localhost", port=PORT) connection.request("GET", "/top-chasers") connection.getresponse() - (event,) = events - request_span = event["spans"][-1] + sentry_sdk.flush() + request_span = [item.payload for item in items if item.type == "span"][0] expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( - trace_id=event["contexts"]["trace"]["trace_id"], + trace_id=request_span["trace_id"], parent_span_id=request_span["span_id"], sampled=1, ) @@ -304,7 +165,7 @@ def send(self, *args, **kwargs) -> None: "sentry-release=foo," "sentry-sample_rate=0.5," "sentry-sampled=%s" - ) % (transaction.trace_id, "true" if transaction.sampled else "false") + ) % request_span["trace_id"] assert request_headers["baggage"] == expected_outgoing_baggage @@ -368,8 +229,15 @@ def send(self, *args, **kwargs) -> None: ], ], ) +@pytest.mark.parametrize("span_streaming", [True, False]) def test_option_trace_propagation_targets( - sentry_init, monkeypatch, trace_propagation_targets, host, path, trace_propagated + sentry_init, + monkeypatch, + trace_propagation_targets, + host, + path, + trace_propagated, + span_streaming, ): # HTTPSConnection.send is passed a string containing (among other things) # the headers on the request. Mock it so we can check the headers, and also @@ -380,6 +248,7 @@ def test_option_trace_propagation_targets( sentry_init( trace_propagation_targets=trace_propagation_targets, traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) headers = { @@ -389,14 +258,14 @@ def test_option_trace_propagation_targets( ) } - transaction = continue_trace(headers) + sentry_sdk.traces.continue_trace(headers) - with start_transaction( - transaction=transaction, + with sentry_sdk.traces.start_span( name="/interactions/other-dogs/new-dog", - op="greeting.sniff", - trace_id="12312012123120121231201212312012", - ) as transaction: + attributes={ + "sentry.op": "greeting.sniff", + }, + ): HTTPSConnection(host).request("GET", path) (request_str,) = mock_send.call_args[0] @@ -414,38 +283,42 @@ def test_option_trace_propagation_targets( assert "baggage" not in request_headers -def test_request_source_disabled(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_request_source_disabled(sentry_init, capture_items, span_streaming): sentry_options = { "traces_sample_rate": 1.0, "enable_http_request_source": False, "http_request_source_threshold_ms": 0, } - sentry_init(**sentry_options) + sentry_init( + **sentry_options, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) - events = capture_events() + items = capture_items("event", "transaction", "span") - with start_transaction(name="foo"): + with sentry_sdk.traces.start_span(name="custom parent"): conn = HTTPConnection("localhost", port=PORT) conn.request("GET", "/foo") conn.getresponse() - (event,) = events - - span = event["spans"][-1] - assert span["description"].startswith("GET") + sentry_sdk.flush() + span = next(item.payload for item in items if item.type == "span") + assert span["name"].startswith("GET") - data = span.get("data", {}) + attributes = span["attributes"] - assert SPANDATA.CODE_LINENO not in data - assert SPANDATA.CODE_NAMESPACE not in data - assert SPANDATA.CODE_FILEPATH not in data - assert SPANDATA.CODE_FUNCTION not in data + assert SPANDATA.CODE_LINE_NUMBER not in attributes + assert SPANDATA.CODE_NAMESPACE not in attributes + assert SPANDATA.CODE_FILE_PATH not in attributes + assert SPANDATA.CODE_FUNCTION not in attributes @pytest.mark.parametrize("enable_http_request_source", [None, True]) +@pytest.mark.parametrize("span_streaming", [True, False]) def test_request_source_enabled( - sentry_init, capture_events, enable_http_request_source + sentry_init, capture_items, enable_http_request_source, span_streaming ): sentry_options = { "traces_sample_rate": 1.0, @@ -454,68 +327,77 @@ def test_request_source_enabled( if enable_http_request_source is not None: sentry_options["enable_http_request_source"] = enable_http_request_source - sentry_init(**sentry_options) + sentry_init( + **sentry_options, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) - events = capture_events() + items = capture_items("event", "transaction", "span") - with start_transaction(name="foo"): + with sentry_sdk.traces.start_span(name="custom parent"): conn = HTTPConnection("localhost", port=PORT) conn.request("GET", "/foo") conn.getresponse() - (event,) = events + sentry_sdk.flush() + span = next(item.payload for item in items if item.type == "span") + assert span["name"].startswith("GET") - span = event["spans"][-1] - assert span["description"].startswith("GET") + attributes = span["attributes"] - data = span.get("data", {}) + assert SPANDATA.CODE_LINE_NUMBER in attributes + assert SPANDATA.CODE_NAMESPACE in attributes + assert SPANDATA.CODE_FILE_PATH in attributes + assert SPANDATA.CODE_FUNCTION in attributes - assert SPANDATA.CODE_LINENO in data - assert SPANDATA.CODE_NAMESPACE in data - assert SPANDATA.CODE_FILEPATH in data - assert SPANDATA.CODE_FUNCTION in data - -def test_request_source(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_request_source(sentry_init, capture_items, span_streaming): sentry_init( traces_sample_rate=1.0, enable_http_request_source=True, http_request_source_threshold_ms=0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() + items = capture_items("event", "transaction", "span") - with start_transaction(name="foo"): + with sentry_sdk.traces.start_span(name="custom parent"): conn = HTTPConnection("localhost", port=PORT) conn.request("GET", "/foo") conn.getresponse() - (event,) = events - - span = event["spans"][-1] - assert span["description"].startswith("GET") + sentry_sdk.flush() + span = next(item.payload for item in items if item.type == "span") + assert span["name"].startswith("GET") - data = span.get("data", {}) + attributes = span["attributes"] - assert SPANDATA.CODE_LINENO in data - assert SPANDATA.CODE_NAMESPACE in data - assert SPANDATA.CODE_FILEPATH in data - assert SPANDATA.CODE_FUNCTION in data + assert SPANDATA.CODE_LINE_NUMBER in attributes + assert SPANDATA.CODE_NAMESPACE in attributes + assert SPANDATA.CODE_FILE_PATH in attributes + assert SPANDATA.CODE_FUNCTION in attributes - assert type(data.get(SPANDATA.CODE_LINENO)) == int - assert data.get(SPANDATA.CODE_LINENO) > 0 - assert data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.stdlib.test_httplib" - assert data.get(SPANDATA.CODE_FILEPATH).endswith( + assert type(attributes.get(SPANDATA.CODE_LINE_NUMBER)) == int + assert attributes.get(SPANDATA.CODE_LINE_NUMBER) > 0 + assert ( + attributes.get(SPANDATA.CODE_NAMESPACE) + == "tests.integrations.stdlib.test_httplib" + ) + assert attributes.get(SPANDATA.CODE_FILE_PATH).endswith( "tests/integrations/stdlib/test_httplib.py" ) - is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep + is_relative_path = attributes.get(SPANDATA.CODE_FILE_PATH)[0] != os.sep assert is_relative_path - assert data.get(SPANDATA.CODE_FUNCTION) == "test_request_source" + assert attributes.get(SPANDATA.CODE_FUNCTION) == "test_request_source" -def test_request_source_with_module_in_search_path(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_request_source_with_module_in_search_path( + sentry_init, capture_items, span_streaming +): """ Test that request source is relative to the path of the module it ran in """ @@ -523,44 +405,48 @@ def test_request_source_with_module_in_search_path(sentry_init, capture_events): traces_sample_rate=1.0, enable_http_request_source=True, http_request_source_threshold_ms=0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() + items = capture_items("event", "transaction", "span") - with start_transaction(name="foo"): + with sentry_sdk.traces.start_span(name="custom parent"): from httplib_helpers.helpers import get_request_with_connection conn = HTTPConnection("localhost", port=PORT) get_request_with_connection(conn, "/foo") - (event,) = events - - span = event["spans"][-1] - assert span["description"].startswith("GET") + sentry_sdk.flush() + span = next(item.payload for item in items if item.type == "span") + assert span["name"].startswith("GET") - data = span.get("data", {}) + attributes = span["attributes"] - assert SPANDATA.CODE_LINENO in data - assert SPANDATA.CODE_NAMESPACE in data - assert SPANDATA.CODE_FILEPATH in data - assert SPANDATA.CODE_FUNCTION in data + assert SPANDATA.CODE_LINE_NUMBER in attributes + assert SPANDATA.CODE_NAMESPACE in attributes + assert SPANDATA.CODE_FILE_PATH in attributes + assert SPANDATA.CODE_FUNCTION in attributes - assert type(data.get(SPANDATA.CODE_LINENO)) == int - assert data.get(SPANDATA.CODE_LINENO) > 0 - assert data.get(SPANDATA.CODE_NAMESPACE) == "httplib_helpers.helpers" - assert data.get(SPANDATA.CODE_FILEPATH) == "httplib_helpers/helpers.py" + assert type(attributes.get(SPANDATA.CODE_LINE_NUMBER)) == int + assert attributes.get(SPANDATA.CODE_LINE_NUMBER) > 0 + assert attributes.get(SPANDATA.CODE_NAMESPACE) == "httplib_helpers.helpers" + assert attributes.get(SPANDATA.CODE_FILE_PATH) == "httplib_helpers/helpers.py" - is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep + is_relative_path = attributes.get(SPANDATA.CODE_FILE_PATH)[0] != os.sep assert is_relative_path - assert data.get(SPANDATA.CODE_FUNCTION) == "get_request_with_connection" + assert attributes.get(SPANDATA.CODE_FUNCTION) == "get_request_with_connection" -def test_no_request_source_if_duration_too_short(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_no_request_source_if_duration_too_short( + sentry_init, capture_items, span_streaming +): sentry_init( traces_sample_rate=1.0, enable_http_request_source=True, http_request_source_threshold_ms=100, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) already_patched_putrequest = HTTPConnection.putrequest @@ -569,34 +455,37 @@ class HttpConnectionWithPatchedSpan(HTTPConnection): def putrequest(self, *args, **kwargs) -> None: already_patched_putrequest(self, *args, **kwargs) span = self._sentrysdk_span # type: ignore - span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) - span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) + span._start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) + span._timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) - events = capture_events() + items = capture_items("event", "transaction", "span") - with start_transaction(name="foo"): + with sentry_sdk.traces.start_span(name="custom parent"): conn = HttpConnectionWithPatchedSpan("localhost", port=PORT) conn.request("GET", "/foo") conn.getresponse() - (event,) = events - - span = event["spans"][-1] - assert span["description"].startswith("GET") + sentry_sdk.flush() + span = next(item.payload for item in items if item.type == "span") + assert span["name"].startswith("GET") - data = span.get("data", {}) + attributes = span["attributes"] - assert SPANDATA.CODE_LINENO not in data - assert SPANDATA.CODE_NAMESPACE not in data - assert SPANDATA.CODE_FILEPATH not in data - assert SPANDATA.CODE_FUNCTION not in data + assert SPANDATA.CODE_LINE_NUMBER not in attributes + assert SPANDATA.CODE_NAMESPACE not in attributes + assert SPANDATA.CODE_FILE_PATH not in attributes + assert SPANDATA.CODE_FUNCTION not in attributes -def test_request_source_if_duration_over_threshold(sentry_init, capture_events): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_request_source_if_duration_over_threshold( + sentry_init, capture_items, span_streaming +): sentry_init( traces_sample_rate=1.0, enable_http_request_source=True, http_request_source_threshold_ms=100, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) already_patched_putrequest = HTTPConnection.putrequest @@ -605,70 +494,87 @@ class HttpConnectionWithPatchedSpan(HTTPConnection): def putrequest(self, *args, **kwargs) -> None: already_patched_putrequest(self, *args, **kwargs) span = self._sentrysdk_span # type: ignore - span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) - span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) + span._start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) + span._timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) - events = capture_events() + items = capture_items("event", "transaction", "span") - with start_transaction(name="foo"): + with sentry_sdk.traces.start_span(name="custom parent"): conn = HttpConnectionWithPatchedSpan("localhost", port=PORT) conn.request("GET", "/foo") conn.getresponse() - (event,) = events - - span = event["spans"][-1] - assert span["description"].startswith("GET") + sentry_sdk.flush() + span = next(item.payload for item in items if item.type == "span") + assert span["name"].startswith("GET") - data = span.get("data", {}) + attributes = span["attributes"] - assert SPANDATA.CODE_LINENO in data - assert SPANDATA.CODE_NAMESPACE in data - assert SPANDATA.CODE_FILEPATH in data - assert SPANDATA.CODE_FUNCTION in data + assert SPANDATA.CODE_LINE_NUMBER in attributes + assert SPANDATA.CODE_NAMESPACE in attributes + assert SPANDATA.CODE_FILE_PATH in attributes + assert SPANDATA.CODE_FUNCTION in attributes - assert type(data.get(SPANDATA.CODE_LINENO)) == int - assert data.get(SPANDATA.CODE_LINENO) > 0 - assert data.get(SPANDATA.CODE_NAMESPACE) == "tests.integrations.stdlib.test_httplib" - assert data.get(SPANDATA.CODE_FILEPATH).endswith( + assert type(attributes.get(SPANDATA.CODE_LINE_NUMBER)) == int + assert attributes.get(SPANDATA.CODE_LINE_NUMBER) > 0 + assert ( + attributes.get(SPANDATA.CODE_NAMESPACE) + == "tests.integrations.stdlib.test_httplib" + ) + assert attributes.get(SPANDATA.CODE_FILE_PATH).endswith( "tests/integrations/stdlib/test_httplib.py" ) - is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep + is_relative_path = attributes.get(SPANDATA.CODE_FILE_PATH)[0] != os.sep assert is_relative_path assert ( - data.get(SPANDATA.CODE_FUNCTION) + attributes.get(SPANDATA.CODE_FUNCTION) == "test_request_source_if_duration_over_threshold" ) -def test_span_origin(sentry_init, capture_events): - sentry_init(traces_sample_rate=1.0, debug=True) - events = capture_events() +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_span_origin(sentry_init, capture_items, span_streaming): + sentry_init( + traces_sample_rate=1.0, + debug=True, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) + items = capture_items("event", "transaction", "span") - with start_transaction(name="foo"): + with sentry_sdk.traces.start_span(name="custom parent"): conn = HTTPConnection("localhost", port=PORT) conn.request("GET", "/foo") conn.getresponse() - (event,) = events - assert event["contexts"]["trace"]["origin"] == "manual" + sentry_sdk.flush() + spans = [item.payload for item in items if item.type == "span"] + assert spans[0]["attributes"]["sentry.origin"] == "manual" - assert event["spans"][0]["op"] == "http.client" - assert event["spans"][0]["origin"] == "auto.http.stdlib.httplib" + assert spans[0]["attributes"]["sentry.op"] == "http.client" + assert spans[0]["origin"] == "auto.http.stdlib.httplib" -def test_http_timeout(monkeypatch, sentry_init, capture_envelopes): +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_http_timeout(monkeypatch, sentry_init, capture_envelopes, span_streaming): mock_readinto = mock.Mock(side_effect=TimeoutError) monkeypatch.setattr(SocketIO, "readinto", mock_readinto) - sentry_init(traces_sample_rate=1.0) + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) envelopes = capture_envelopes() with pytest.raises(TimeoutError): - with start_transaction(op="op", name="name"): + with sentry_sdk.traces.start_span( + name="name", + attributes={ + "sentry.op": "op", + }, + ): conn = HTTPConnection("localhost", port=PORT) conn.request("GET", "/bla") conn.getresponse() @@ -678,27 +584,36 @@ def test_http_timeout(monkeypatch, sentry_init, capture_envelopes): assert len(transaction["spans"]) == 1 span = transaction["spans"][0] - assert span["op"] == "http.client" - assert span["description"] == f"GET http://localhost:{PORT}/bla" # noqa: E231 + assert span["attributes"]["sentry.op"] == "http.client" + assert span["name"] == f"GET http://localhost:{PORT}/bla" # noqa: E231 @pytest.mark.parametrize("tunnel_port", [8080, None]) -def test_proxy_http_tunnel(sentry_init, capture_events, tunnel_port): - sentry_init(traces_sample_rate=1.0) - events = capture_events() +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_proxy_http_tunnel(sentry_init, capture_items, tunnel_port, span_streaming): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) + items = capture_items("event", "transaction", "span") - with start_transaction(name="test_transaction"): + with sentry_sdk.traces.start_span(name="custom parent"): conn = HTTPConnection("localhost", PROXY_PORT) conn.set_tunnel("api.example.com", tunnel_port) conn.request("GET", "/foo") conn.getresponse() - (event,) = events - (span,) = event["spans"] + sentry_sdk.flush() + spans = [item.payload for item in items if item.type == "span"] + (span,) = ( + span + for span in spans + if span["attributes"].get("sentry.op") == "auto.http.stdlib.httplib" + ) port_modifier = f":{tunnel_port}" if tunnel_port else "" - assert span["description"] == f"GET http://api.example.com{port_modifier}/foo" - assert span["data"]["url"] == f"http://api.example.com{port_modifier}/foo" - assert span["data"][SPANDATA.HTTP_METHOD] == "GET" - assert span["data"][SPANDATA.NETWORK_PEER_ADDRESS] == "localhost" - assert span["data"][SPANDATA.NETWORK_PEER_PORT] == PROXY_PORT + assert span["name"] == f"GET http://api.example.com{port_modifier}/foo" + assert span["attributes"]["url"] == f"http://api.example.com{port_modifier}/foo" + assert span["attributes"][SPANDATA.HTTP_METHOD] == "GET" + assert span["attributes"][SPANDATA.NETWORK_PEER_ADDRESS] == "localhost" + assert span["attributes"][SPANDATA.NETWORK_PEER_PORT] == PROXY_PORT From 42d8c7343e3275a0edfbdf522dc4352579d716ed Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 12:00:28 +0200 Subject: [PATCH 04/26] . --- tests/integrations/stdlib/test_httplib.py | 88 ++++++++++++++--------- 1 file changed, 56 insertions(+), 32 deletions(-) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 5890962ca7..48fa85ec9e 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -1,4 +1,5 @@ import os +import socket import datetime from http.client import HTTPConnection, HTTPSConnection from http.server import BaseHTTPRequestHandler, HTTPServer @@ -203,11 +204,13 @@ def test_httplib_misuse(sentry_init, capture_events, request): def test_outgoing_trace_headers(sentry_init, capture_events): - original_send = HTTPConnection.send + sentry_init(traces_sample_rate=1.0) + + already_patched_getresponse = HTTPSConnection.getresponse request_headers = {} - class HttpConnectionRecordingRequestHeaders(HTTPConnection): + class HTTPSConnectionRecordingRequestHeaders(HTTPSConnection): def send(self, *args, **kwargs) -> None: request_str = args[0] for line in request_str.decode("utf-8").split("\r\n")[1:]: @@ -215,9 +218,14 @@ def send(self, *args, **kwargs) -> None: key, val = line.split(": ") request_headers[key] = val - original_send(self, *args, **kwargs) + server_sock, client_sock = socket.socketpair() + server_sock.sendall(b"HTTP/1.1 200 OK\r\n\r\n") + server_sock.close() + self.sock = client_sock + + def getresponse(self, *args, **kwargs): + return already_patched_getresponse(self, *args, **kwargs) - sentry_init(traces_sample_rate=1.0) events = capture_events() headers = { @@ -237,7 +245,7 @@ def send(self, *args, **kwargs) -> None: op="greeting.sniff", trace_id="12312012123120121231201212312012", ) as transaction: - connection = HttpConnectionRecordingRequestHeaders("localhost", port=PORT) + connection = HTTPSConnectionRecordingRequestHeaders("localhost", port=PORT) connection.request("GET", "/top-chasers") connection.getresponse() @@ -262,11 +270,13 @@ def send(self, *args, **kwargs) -> None: def test_outgoing_trace_headers_head_sdk(sentry_init, capture_events): - original_send = HTTPConnection.send + sentry_init(traces_sample_rate=0.5, release="foo") + + already_patched_getresponse = HTTPSConnection.getresponse request_headers = {} - class HttpConnectionRecordingRequestHeaders(HTTPConnection): + class HTTPSConnectionRecordingRequestHeaders(HTTPSConnection): def send(self, *args, **kwargs) -> None: request_str = args[0] for line in request_str.decode("utf-8").split("\r\n")[1:]: @@ -274,16 +284,21 @@ def send(self, *args, **kwargs) -> None: key, val = line.split(": ") request_headers[key] = val - original_send(self, *args, **kwargs) + server_sock, client_sock = socket.socketpair() + server_sock.sendall(b"HTTP/1.1 200 OK\r\n\r\n") + server_sock.close() + self.sock = client_sock + + def getresponse(self, *args, **kwargs): + return already_patched_getresponse(self, *args, **kwargs) - sentry_init(traces_sample_rate=0.5, release="foo") events = capture_events() with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=250000): transaction = continue_trace({}) with start_transaction(transaction=transaction, name="Head SDK tx") as transaction: - connection = HttpConnectionRecordingRequestHeaders("localhost", port=PORT) + connection = HTTPSConnectionRecordingRequestHeaders("localhost", port=PORT) connection.request("GET", "/top-chasers") connection.getresponse() @@ -369,19 +384,33 @@ def send(self, *args, **kwargs) -> None: ], ) def test_option_trace_propagation_targets( - sentry_init, monkeypatch, trace_propagation_targets, host, path, trace_propagated + sentry_init, trace_propagation_targets, host, path, trace_propagated ): - # HTTPSConnection.send is passed a string containing (among other things) - # the headers on the request. Mock it so we can check the headers, and also - # so it doesn't try to actually talk to the internet. - mock_send = mock.Mock() - monkeypatch.setattr(HTTPSConnection, "send", mock_send) - sentry_init( trace_propagation_targets=trace_propagation_targets, traces_sample_rate=1.0, ) + already_patched_getresponse = HTTPSConnection.getresponse + + request_headers = {} + + class HTTPSConnectionRecordingRequestHeaders(HTTPSConnection): + def send(self, *args, **kwargs) -> None: + request_str = args[0] + for line in request_str.decode("utf-8").split("\r\n")[1:]: + if line: + key, val = line.split(": ") + request_headers[key] = val + + server_sock, client_sock = socket.socketpair() + server_sock.sendall(b"HTTP/1.1 200 OK\r\n\r\n") + server_sock.close() + self.sock = client_sock + + def getresponse(self, *args, **kwargs): + return already_patched_getresponse(self, *args, **kwargs) + headers = { "baggage": ( "sentry-trace_id=771a43a4192642f0b136d5159a501700, " @@ -397,21 +426,16 @@ def test_option_trace_propagation_targets( op="greeting.sniff", trace_id="12312012123120121231201212312012", ) as transaction: - HTTPSConnection(host).request("GET", path) - - (request_str,) = mock_send.call_args[0] - request_headers = {} - for line in request_str.decode("utf-8").split("\r\n")[1:]: - if line: - key, val = line.split(": ") - request_headers[key] = val - - if trace_propagated: - assert "sentry-trace" in request_headers - assert "baggage" in request_headers - else: - assert "sentry-trace" not in request_headers - assert "baggage" not in request_headers + connection = HTTPSConnectionRecordingRequestHeaders(host) + connection.request("GET", path) + connection.getresponse() + + if trace_propagated: + assert "sentry-trace" in request_headers + assert "baggage" in request_headers + else: + assert "sentry-trace" not in request_headers + assert "baggage" not in request_headers def test_request_source_disabled(sentry_init, capture_events): From be7e413f728341e6a2b70aba1807520d7a3eef28 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 13:32:22 +0200 Subject: [PATCH 05/26] remove print --- sentry_sdk/integrations/stdlib.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index b58663afaa..8fa9350368 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -159,8 +159,6 @@ def putrequest( def getresponse(self: "HTTPConnection", *args: "Any", **kwargs: "Any") -> "Any": span = getattr(self, "_sentrysdk_span", None) - print("span is here") - if span is None: return real_getresponse(self, *args, **kwargs) From 1c9f953acd5df008e26dbc7821641a6f49c23391 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 13:35:48 +0200 Subject: [PATCH 06/26] test(stdlib): Overwrite timestamps in getresponse instead of putrequest --- tests/integrations/stdlib/test_httplib.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 48fa85ec9e..0d7d2e0511 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -587,14 +587,15 @@ def test_no_request_source_if_duration_too_short(sentry_init, capture_events): http_request_source_threshold_ms=100, ) - already_patched_putrequest = HTTPConnection.putrequest + already_patched_getresponse = HTTPConnection.getresponse class HttpConnectionWithPatchedSpan(HTTPConnection): - def putrequest(self, *args, **kwargs) -> None: - already_patched_putrequest(self, *args, **kwargs) + def getresponse(self, *args, **kwargs): + response = already_patched_getresponse(self, *args, **kwargs) span = self._sentrysdk_span # type: ignore span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) + return response events = capture_events() @@ -623,14 +624,15 @@ def test_request_source_if_duration_over_threshold(sentry_init, capture_events): http_request_source_threshold_ms=100, ) - already_patched_putrequest = HTTPConnection.putrequest + already_patched_getresponse = HTTPConnection.getresponse class HttpConnectionWithPatchedSpan(HTTPConnection): - def putrequest(self, *args, **kwargs) -> None: - already_patched_putrequest(self, *args, **kwargs) + def getresponse(self, *args, **kwargs): + response = already_patched_getresponse(self, *args, **kwargs) span = self._sentrysdk_span # type: ignore span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) + return response events = capture_events() From 22b2886272591e9ec53fe7f80c568a1eda0509b9 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 14:05:05 +0200 Subject: [PATCH 07/26] update tests --- tests/integrations/stdlib/test_httplib.py | 53 ++++++++++++----------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 0d7d2e0511..6f962d6ad9 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -11,6 +11,7 @@ import pytest +import sentry_sdk from sentry_sdk import capture_message, start_transaction, continue_trace from sentry_sdk.consts import MATCH_ALL, SPANDATA from sentry_sdk.integrations.stdlib import StdlibIntegration @@ -587,22 +588,23 @@ def test_no_request_source_if_duration_too_short(sentry_init, capture_events): http_request_source_threshold_ms=100, ) - already_patched_getresponse = HTTPConnection.getresponse + add_http_request_source = sentry_sdk.tracing_utils.add_http_request_source - class HttpConnectionWithPatchedSpan(HTTPConnection): - def getresponse(self, *args, **kwargs): - response = already_patched_getresponse(self, *args, **kwargs) - span = self._sentrysdk_span # type: ignore - span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) - span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) - return response + def add_http_request_source_with_pinned_timestamps(span): + span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) + span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) + return add_http_request_source(span) events = capture_events() - with start_transaction(name="foo"): - conn = HttpConnectionWithPatchedSpan("localhost", port=PORT) - conn.request("GET", "/foo") - conn.getresponse() + with mock.patch( + "sentry_sdk.integrations.stdlib.add_http_request_source", + add_http_request_source_with_pinned_timestamps, + ): + with start_transaction(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() (event,) = events @@ -624,22 +626,23 @@ def test_request_source_if_duration_over_threshold(sentry_init, capture_events): http_request_source_threshold_ms=100, ) - already_patched_getresponse = HTTPConnection.getresponse + add_http_request_source = sentry_sdk.tracing_utils.add_http_request_source - class HttpConnectionWithPatchedSpan(HTTPConnection): - def getresponse(self, *args, **kwargs): - response = already_patched_getresponse(self, *args, **kwargs) - span = self._sentrysdk_span # type: ignore - span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) - span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) - return response + def add_http_request_source_with_pinned_timestamps(span): + span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) + span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) + return add_http_request_source(span) events = capture_events() - with start_transaction(name="foo"): - conn = HttpConnectionWithPatchedSpan("localhost", port=PORT) - conn.request("GET", "/foo") - conn.getresponse() + with mock.patch( + "sentry_sdk.integrations.stdlib.add_http_request_source", + add_http_request_source_with_pinned_timestamps, + ): + with start_transaction(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() (event,) = events @@ -665,7 +668,7 @@ def getresponse(self, *args, **kwargs): assert ( data.get(SPANDATA.CODE_FUNCTION) - == "test_request_source_if_duration_over_threshold" + == "add_http_request_source_with_pinned_timestamps" ) From 1c549fb855cdd58ebd8011397a13533e70f8e5cf Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 14:20:39 +0200 Subject: [PATCH 08/26] remove reason attribute --- sentry_sdk/integrations/stdlib.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 8fa9350368..8baa3659b8 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -166,8 +166,6 @@ def getresponse(self: "HTTPConnection", *args: "Any", **kwargs: "Any") -> "Any": rv = real_getresponse(self, *args, **kwargs) if isinstance(span, StreamedSpan): - span.set_attribute("reason", rv.reason) - status_code = int(rv.status) span.status = "error" if status_code >= 400 else "ok" span.set_attribute("http.response.status_code", status_code) From 5b3406a7062d286d7344ade62c9c7b60c5d87277 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 14:45:26 +0200 Subject: [PATCH 09/26] parameterize tests --- tests/integrations/stdlib/test_httplib.py | 864 +++++++++++++++------- 1 file changed, 615 insertions(+), 249 deletions(-) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 7dd1938006..11b4d06154 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -10,6 +10,7 @@ import pytest import sentry_sdk +from sentry_sdk import start_transaction, continue_trace from sentry_sdk.consts import MATCH_ALL, SPANDATA from tests.conftest import create_mock_http_server, get_free_port @@ -42,7 +43,10 @@ def create_mock_proxy_server(): @pytest.mark.parametrize("span_streaming", [True, False]) -def test_empty_realurl(sentry_init, span_streaming): +def test_empty_realurl( + sentry_init, + span_streaming, +): """ Ensure that after using sentry_sdk.init you can putrequest a None url. @@ -55,8 +59,17 @@ def test_empty_realurl(sentry_init, span_streaming): HTTPConnection("localhost", port=PORT).putrequest("POST", None) -def test_outgoing_trace_headers(sentry_init, capture_items): - sentry_init(traces_sample_rate=1.0) +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_outgoing_trace_headers( + sentry_init, + capture_events, + capture_items, + span_streaming, +): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) already_patched_getresponse = HTTPSConnection.getresponse @@ -78,8 +91,6 @@ def send(self, *args, **kwargs) -> None: def getresponse(self, *args, **kwargs): return already_patched_getresponse(self, *args, **kwargs) - items = capture_items("event", "transaction", "span") - headers = { "sentry-trace": "771a43a4192642f0b136d5159a501700-1234567890abcdef-1", "baggage": ( @@ -89,25 +100,49 @@ def getresponse(self, *args, **kwargs): ), } - sentry_sdk.traces.continue_trace(headers) - - with sentry_sdk.traces.start_span( - name="/interactions/other-dogs/new-dog", - attributes={ - "sentry.op": "greeting.sniff", - }, - ): - connection = HTTPSConnectionRecordingRequestHeaders("localhost", port=PORT) - connection.request("GET", "/top-chasers") - connection.getresponse() - - sentry_sdk.flush() - request_span = next(item.payload for item in items if item.type == "span") - expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( - trace_id=request_span["trace_id"], - parent_span_id=request_span["span_id"], - sampled=1, - ) + if span_streaming: + items = capture_items("span") + sentry_sdk.traces.continue_trace(headers) + + with sentry_sdk.traces.start_span( + name="/interactions/other-dogs/new-dog", + attributes={ + "sentry.op": "greeting.sniff", + }, + ): + connection = HTTPSConnectionRecordingRequestHeaders("localhost", port=PORT) + connection.request("GET", "/top-chasers") + connection.getresponse() + + sentry_sdk.flush() + request_span = next(item.payload for item in items if item.type == "span") + expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=request_span["trace_id"], + parent_span_id=request_span["span_id"], + sampled=1, + ) + else: + events = capture_events() + transaction = continue_trace(headers) + + with start_transaction( + transaction=transaction, + name="/interactions/other-dogs/new-dog", + op="greeting.sniff", + trace_id="12312012123120121231201212312012", + ) as transaction: + connection = HTTPSConnectionRecordingRequestHeaders("localhost", port=PORT) + connection.request("GET", "/top-chasers") + connection.getresponse() + + (event,) = events + request_span = event["spans"][-1] + expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=event["contexts"]["trace"]["trace_id"], + parent_span_id=request_span["span_id"], + sampled=1, + ) + assert request_headers["sentry-trace"] == expected_sentry_trace expected_outgoing_baggage = ( @@ -121,8 +156,18 @@ def getresponse(self, *args, **kwargs): assert request_headers["baggage"] == expected_outgoing_baggage -def test_outgoing_trace_headers_head_sdk(sentry_init, capture_items): - sentry_init(traces_sample_rate=0.5, release="foo") +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_outgoing_trace_headers_head_sdk( + sentry_init, + capture_events, + capture_items, + span_streaming, +): + sentry_init( + traces_sample_rate=0.5, + release="foo", + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) already_patched_getresponse = HTTPSConnection.getresponse @@ -144,35 +189,68 @@ def send(self, *args, **kwargs) -> None: def getresponse(self, *args, **kwargs): return already_patched_getresponse(self, *args, **kwargs) - items = capture_items("event", "transaction", "span") - - with mock.patch("sentry_sdk.tracing_utils.Random.randrange", return_value=250000): - sentry_sdk.traces.continue_trace({}) + if span_streaming: + items = capture_items("span") - with sentry_sdk.traces.start_span(name="Head SDK tx"): - connection = HTTPSConnectionRecordingRequestHeaders("localhost", port=PORT) - connection.request("GET", "/top-chasers") - connection.getresponse() + with mock.patch( + "sentry_sdk.tracing_utils.Random.randrange", return_value=250000 + ): + sentry_sdk.traces.continue_trace({}) + + with sentry_sdk.traces.start_span(name="Head SDK tx"): + connection = HTTPSConnectionRecordingRequestHeaders("localhost", port=PORT) + connection.request("GET", "/top-chasers") + connection.getresponse() + + sentry_sdk.flush() + request_span = next(item.payload for item in items if item.type == "span") + expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=request_span["trace_id"], + parent_span_id=request_span["span_id"], + sampled=1, + ) - sentry_sdk.flush() - request_span = next(item.payload for item in items if item.type == "span") - expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( - trace_id=request_span["trace_id"], - parent_span_id=request_span["span_id"], - sampled=1, - ) + expected_outgoing_baggage = ( + "sentry-trace_id=%s," + "sentry-sample_rand=0.250000," + "sentry-environment=production," + "sentry-release=foo," + "sentry-sample_rate=0.5," + "sentry-sampled=%s" + ) % request_span["trace_id"] + else: + events = capture_events() - assert request_headers["sentry-trace"] == expected_sentry_trace + with mock.patch( + "sentry_sdk.tracing_utils.Random.randrange", return_value=250000 + ): + transaction = continue_trace({}) + + with start_transaction( + transaction=transaction, name="Head SDK tx" + ) as transaction: + connection = HTTPSConnectionRecordingRequestHeaders("localhost", port=PORT) + connection.request("GET", "/top-chasers") + connection.getresponse() + + (event,) = events + request_span = event["spans"][-1] + expected_sentry_trace = "{trace_id}-{parent_span_id}-{sampled}".format( + trace_id=event["contexts"]["trace"]["trace_id"], + parent_span_id=request_span["span_id"], + sampled=1, + ) - expected_outgoing_baggage = ( - "sentry-trace_id=%s," - "sentry-sample_rand=0.250000," - "sentry-environment=production," - "sentry-release=foo," - "sentry-sample_rate=0.5," - "sentry-sampled=%s" - ) % request_span["trace_id"] + expected_outgoing_baggage = ( + "sentry-trace_id=%s," + "sentry-sample_rand=0.250000," + "sentry-environment=production," + "sentry-release=foo," + "sentry-sample_rate=0.5," + "sentry-sampled=%s" + ) % (transaction.trace_id, "true" if transaction.sampled else "false") + assert request_headers["sentry-trace"] == expected_sentry_trace assert request_headers["baggage"] == expected_outgoing_baggage @@ -277,17 +355,30 @@ def getresponse(self, *args, **kwargs): ) } - sentry_sdk.traces.continue_trace(headers) + if span_streaming: + sentry_sdk.traces.continue_trace(headers) - with sentry_sdk.traces.start_span( - name="/interactions/other-dogs/new-dog", - attributes={ - "sentry.op": "greeting.sniff", - }, - ): - connection = HTTPSConnectionRecordingRequestHeaders(host) - connection.request("GET", path) - connection.getresponse() + with sentry_sdk.traces.start_span( + name="/interactions/other-dogs/new-dog", + attributes={ + "sentry.op": "greeting.sniff", + }, + ): + connection = HTTPSConnectionRecordingRequestHeaders(host) + connection.request("GET", path) + connection.getresponse() + else: + transaction = continue_trace(headers) + + with start_transaction( + transaction=transaction, + name="/interactions/other-dogs/new-dog", + op="greeting.sniff", + trace_id="12312012123120121231201212312012", + ) as transaction: + connection = HTTPSConnectionRecordingRequestHeaders(host) + connection.request("GET", path) + connection.getresponse() if trace_propagated: assert "sentry-trace" in request_headers @@ -298,7 +389,12 @@ def getresponse(self, *args, **kwargs): @pytest.mark.parametrize("span_streaming", [True, False]) -def test_request_source_disabled(sentry_init, capture_items, span_streaming): +def test_request_source_disabled( + sentry_init, + capture_events, + capture_items, + span_streaming, +): sentry_options = { "traces_sample_rate": 1.0, "enable_http_request_source": False, @@ -310,107 +406,196 @@ def test_request_source_disabled(sentry_init, capture_items, span_streaming): _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - items = capture_items("event", "transaction", "span") + if span_streaming: + items = capture_items("span") - with sentry_sdk.traces.start_span(name="custom parent"): - conn = HTTPConnection("localhost", port=PORT) - conn.request("GET", "/foo") - conn.getresponse() + with sentry_sdk.traces.start_span(name="custom parent"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() + + sentry_sdk.flush() + span = next(item.payload for item in items if item.type == "span") + assert span["name"].startswith("GET") + + attributes = span["attributes"] + + assert SPANDATA.CODE_LINE_NUMBER not in attributes + assert SPANDATA.CODE_NAMESPACE not in attributes + assert SPANDATA.CODE_FILE_PATH not in attributes + assert SPANDATA.CODE_FUNCTION not in attributes + else: + events = capture_events() - sentry_sdk.flush() - span = next(item.payload for item in items if item.type == "span") - assert span["name"].startswith("GET") + with start_transaction(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() - attributes = span["attributes"] + (event,) = events - assert SPANDATA.CODE_LINE_NUMBER not in attributes - assert SPANDATA.CODE_NAMESPACE not in attributes - assert SPANDATA.CODE_FILE_PATH not in attributes - assert SPANDATA.CODE_FUNCTION not in attributes + span = event["spans"][-1] + assert span["description"].startswith("GET") + + data = span.get("data", {}) + + assert SPANDATA.CODE_LINENO not in data + assert SPANDATA.CODE_NAMESPACE not in data + assert SPANDATA.CODE_FILEPATH not in data + assert SPANDATA.CODE_FUNCTION not in data @pytest.mark.parametrize("enable_http_request_source", [None, True]) @pytest.mark.parametrize("span_streaming", [True, False]) def test_request_source_enabled( - sentry_init, capture_items, enable_http_request_source, span_streaming + sentry_init, + capture_events, + capture_items, + enable_http_request_source, + span_streaming, ): sentry_options = { "traces_sample_rate": 1.0, "http_request_source_threshold_ms": 0, "_experiments": {"trace_lifecycle": "stream" if span_streaming else "static"}, } + if enable_http_request_source is not None: sentry_options["enable_http_request_source"] = enable_http_request_source - sentry_init( - **sentry_options, - ) + if span_streaming: + sentry_init( + **sentry_options, + ) - items = capture_items("event", "transaction", "span") + items = capture_items("span") - with sentry_sdk.traces.start_span(name="custom parent"): - conn = HTTPConnection("localhost", port=PORT) - conn.request("GET", "/foo") - conn.getresponse() + with sentry_sdk.traces.start_span(name="custom parent"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() - sentry_sdk.flush() - span = next(item.payload for item in items if item.type == "span") - assert span["name"].startswith("GET") + sentry_sdk.flush() + span = next(item.payload for item in items if item.type == "span") + assert span["name"].startswith("GET") - attributes = span["attributes"] + attributes = span["attributes"] - assert SPANDATA.CODE_LINE_NUMBER in attributes - assert SPANDATA.CODE_NAMESPACE in attributes - assert SPANDATA.CODE_FILE_PATH in attributes - assert SPANDATA.CODE_FUNCTION in attributes + assert SPANDATA.CODE_LINE_NUMBER in attributes + assert SPANDATA.CODE_NAMESPACE in attributes + assert SPANDATA.CODE_FILE_PATH in attributes + assert SPANDATA.CODE_FUNCTION in attributes + else: + sentry_init(**sentry_options) + + events = capture_events() + + with start_transaction(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() + + (event,) = events + + span = event["spans"][-1] + assert span["description"].startswith("GET") + + data = span.get("data", {}) + + assert SPANDATA.CODE_LINENO in data + assert SPANDATA.CODE_NAMESPACE in data + assert SPANDATA.CODE_FILEPATH in data + assert SPANDATA.CODE_FUNCTION in data @pytest.mark.parametrize("span_streaming", [True, False]) -def test_request_source(sentry_init, capture_items, span_streaming): +def test_request_source( + sentry_init, + capture_events, + capture_items, + span_streaming, +): sentry_init( traces_sample_rate=1.0, enable_http_request_source=True, http_request_source_threshold_ms=0, _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) + if span_streaming: + items = capture_items("span") - items = capture_items("event", "transaction", "span") + with sentry_sdk.traces.start_span(name="custom parent"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() + + sentry_sdk.flush() + span = next(item.payload for item in items if item.type == "span") + assert span["name"].startswith("GET") - with sentry_sdk.traces.start_span(name="custom parent"): - conn = HTTPConnection("localhost", port=PORT) - conn.request("GET", "/foo") - conn.getresponse() + attributes = span["attributes"] - sentry_sdk.flush() - span = next(item.payload for item in items if item.type == "span") - assert span["name"].startswith("GET") + assert SPANDATA.CODE_LINE_NUMBER in attributes + assert SPANDATA.CODE_NAMESPACE in attributes + assert SPANDATA.CODE_FILE_PATH in attributes + assert SPANDATA.CODE_FUNCTION in attributes - attributes = span["attributes"] + assert type(attributes.get(SPANDATA.CODE_LINE_NUMBER)) == int + assert attributes.get(SPANDATA.CODE_LINE_NUMBER) > 0 + assert ( + attributes.get(SPANDATA.CODE_NAMESPACE) + == "tests.integrations.stdlib.test_httplib" + ) + assert attributes.get(SPANDATA.CODE_FILE_PATH).endswith( + "tests/integrations/stdlib/test_httplib.py" + ) - assert SPANDATA.CODE_LINE_NUMBER in attributes - assert SPANDATA.CODE_NAMESPACE in attributes - assert SPANDATA.CODE_FILE_PATH in attributes - assert SPANDATA.CODE_FUNCTION in attributes + is_relative_path = attributes.get(SPANDATA.CODE_FILE_PATH)[0] != os.sep + assert is_relative_path - assert type(attributes.get(SPANDATA.CODE_LINE_NUMBER)) == int - assert attributes.get(SPANDATA.CODE_LINE_NUMBER) > 0 - assert ( - attributes.get(SPANDATA.CODE_NAMESPACE) - == "tests.integrations.stdlib.test_httplib" - ) - assert attributes.get(SPANDATA.CODE_FILE_PATH).endswith( - "tests/integrations/stdlib/test_httplib.py" - ) + assert attributes.get(SPANDATA.CODE_FUNCTION) == "test_request_source" + else: + events = capture_events() + + with start_transaction(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() - is_relative_path = attributes.get(SPANDATA.CODE_FILE_PATH)[0] != os.sep - assert is_relative_path + (event,) = events - assert attributes.get(SPANDATA.CODE_FUNCTION) == "test_request_source" + span = event["spans"][-1] + assert span["description"].startswith("GET") + + data = span.get("data", {}) + + assert SPANDATA.CODE_LINENO in data + assert SPANDATA.CODE_NAMESPACE in data + assert SPANDATA.CODE_FILEPATH in data + assert SPANDATA.CODE_FUNCTION in data + + assert type(data.get(SPANDATA.CODE_LINENO)) == int + assert data.get(SPANDATA.CODE_LINENO) > 0 + assert ( + data.get(SPANDATA.CODE_NAMESPACE) + == "tests.integrations.stdlib.test_httplib" + ) + assert data.get(SPANDATA.CODE_FILEPATH).endswith( + "tests/integrations/stdlib/test_httplib.py" + ) + + is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep + assert is_relative_path + + assert data.get(SPANDATA.CODE_FUNCTION) == "test_request_source" @pytest.mark.parametrize("span_streaming", [True, False]) def test_request_source_with_module_in_search_path( - sentry_init, capture_items, span_streaming + sentry_init, + capture_events, + capture_items, + span_streaming, ): """ Test that request source is relative to the path of the module it ran in @@ -421,40 +606,73 @@ def test_request_source_with_module_in_search_path( http_request_source_threshold_ms=0, _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) + if span_streaming: + items = capture_items("span") + + with sentry_sdk.traces.start_span(name="custom parent"): + from httplib_helpers.helpers import get_request_with_connection + + conn = HTTPConnection("localhost", port=PORT) + get_request_with_connection(conn, "/foo") - items = capture_items("event", "transaction", "span") + sentry_sdk.flush() + span = next(item.payload for item in items if item.type == "span") + assert span["name"].startswith("GET") - with sentry_sdk.traces.start_span(name="custom parent"): - from httplib_helpers.helpers import get_request_with_connection + attributes = span["attributes"] - conn = HTTPConnection("localhost", port=PORT) - get_request_with_connection(conn, "/foo") + assert SPANDATA.CODE_LINE_NUMBER in attributes + assert SPANDATA.CODE_NAMESPACE in attributes + assert SPANDATA.CODE_FILE_PATH in attributes + assert SPANDATA.CODE_FUNCTION in attributes - sentry_sdk.flush() - span = next(item.payload for item in items if item.type == "span") - assert span["name"].startswith("GET") + assert type(attributes.get(SPANDATA.CODE_LINE_NUMBER)) == int + assert attributes.get(SPANDATA.CODE_LINE_NUMBER) > 0 + assert attributes.get(SPANDATA.CODE_NAMESPACE) == "httplib_helpers.helpers" + assert attributes.get(SPANDATA.CODE_FILE_PATH) == "httplib_helpers/helpers.py" - attributes = span["attributes"] + is_relative_path = attributes.get(SPANDATA.CODE_FILE_PATH)[0] != os.sep + assert is_relative_path - assert SPANDATA.CODE_LINE_NUMBER in attributes - assert SPANDATA.CODE_NAMESPACE in attributes - assert SPANDATA.CODE_FILE_PATH in attributes - assert SPANDATA.CODE_FUNCTION in attributes + assert attributes.get(SPANDATA.CODE_FUNCTION) == "get_request_with_connection" + else: + events = capture_events() + + with start_transaction(name="foo"): + from httplib_helpers.helpers import get_request_with_connection + + conn = HTTPConnection("localhost", port=PORT) + get_request_with_connection(conn, "/foo") + + (event,) = events + + span = event["spans"][-1] + assert span["description"].startswith("GET") - assert type(attributes.get(SPANDATA.CODE_LINE_NUMBER)) == int - assert attributes.get(SPANDATA.CODE_LINE_NUMBER) > 0 - assert attributes.get(SPANDATA.CODE_NAMESPACE) == "httplib_helpers.helpers" - assert attributes.get(SPANDATA.CODE_FILE_PATH) == "httplib_helpers/helpers.py" + data = span.get("data", {}) - is_relative_path = attributes.get(SPANDATA.CODE_FILE_PATH)[0] != os.sep - assert is_relative_path + assert SPANDATA.CODE_LINENO in data + assert SPANDATA.CODE_NAMESPACE in data + assert SPANDATA.CODE_FILEPATH in data + assert SPANDATA.CODE_FUNCTION in data - assert attributes.get(SPANDATA.CODE_FUNCTION) == "get_request_with_connection" + assert type(data.get(SPANDATA.CODE_LINENO)) == int + assert data.get(SPANDATA.CODE_LINENO) > 0 + assert data.get(SPANDATA.CODE_NAMESPACE) == "httplib_helpers.helpers" + assert data.get(SPANDATA.CODE_FILEPATH) == "httplib_helpers/helpers.py" + + is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep + assert is_relative_path + + assert data.get(SPANDATA.CODE_FUNCTION) == "get_request_with_connection" @pytest.mark.parametrize("span_streaming", [True, False]) def test_no_request_source_if_duration_too_short( - sentry_init, capture_items, span_streaming + sentry_init, + capture_events, + capture_items, + span_streaming, ): sentry_init( traces_sample_rate=1.0, @@ -466,36 +684,67 @@ def test_no_request_source_if_duration_too_short( add_http_request_source = sentry_sdk.tracing_utils.add_http_request_source def add_http_request_source_with_pinned_timestamps(span): - span._start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) - span._timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) - return add_http_request_source(span) + if span_streaming: + span._start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) + span._timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) + return add_http_request_source(span) + else: + span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) + span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) + return add_http_request_source(span) + + if span_streaming: + items = capture_items("span") + with mock.patch( + "sentry_sdk.integrations.stdlib.add_http_request_source", + add_http_request_source_with_pinned_timestamps, + ): + with sentry_sdk.traces.start_span(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() - items = capture_items("event", "transaction", "span") + sentry_sdk.flush() + span = next(item.payload for item in items if item.type == "span") + assert span["name"].startswith("GET") - with mock.patch( - "sentry_sdk.integrations.stdlib.add_http_request_source", - add_http_request_source_with_pinned_timestamps, - ): - with sentry_sdk.traces.start_span(name="foo"): - conn = HTTPConnection("localhost", port=PORT) - conn.request("GET", "/foo") - conn.getresponse() + attributes = span["attributes"] + + assert SPANDATA.CODE_LINE_NUMBER not in attributes + assert SPANDATA.CODE_NAMESPACE not in attributes + assert SPANDATA.CODE_FILE_PATH not in attributes + assert SPANDATA.CODE_FUNCTION not in attributes + else: + events = capture_events() + + with mock.patch( + "sentry_sdk.integrations.stdlib.add_http_request_source", + add_http_request_source_with_pinned_timestamps, + ): + with start_transaction(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() - sentry_sdk.flush() - span = next(item.payload for item in items if item.type == "span") - assert span["name"].startswith("GET") + (event,) = events - attributes = span["attributes"] + span = event["spans"][-1] + assert span["description"].startswith("GET") - assert SPANDATA.CODE_LINE_NUMBER not in attributes - assert SPANDATA.CODE_NAMESPACE not in attributes - assert SPANDATA.CODE_FILE_PATH not in attributes - assert SPANDATA.CODE_FUNCTION not in attributes + data = span.get("data", {}) + + assert SPANDATA.CODE_LINENO not in data + assert SPANDATA.CODE_NAMESPACE not in data + assert SPANDATA.CODE_FILEPATH not in data + assert SPANDATA.CODE_FUNCTION not in data @pytest.mark.parametrize("span_streaming", [True, False]) def test_request_source_if_duration_over_threshold( - sentry_init, capture_items, span_streaming + sentry_init, + capture_events, + capture_items, + span_streaming, ): sentry_init( traces_sample_rate=1.0, @@ -507,75 +756,149 @@ def test_request_source_if_duration_over_threshold( add_http_request_source = sentry_sdk.tracing_utils.add_http_request_source def add_http_request_source_with_pinned_timestamps(span): - span._start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) - span._timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) - return add_http_request_source(span) + if span_streaming: + span._start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) + span._timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) + return add_http_request_source(span) + else: + span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) + span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) + return add_http_request_source(span) + + if span_streaming: + items = capture_items("span") + + with mock.patch( + "sentry_sdk.integrations.stdlib.add_http_request_source", + add_http_request_source_with_pinned_timestamps, + ): + with sentry_sdk.traces.start_span(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() + + sentry_sdk.flush() + span = next(item.payload for item in items if item.type == "span") + assert span["name"].startswith("GET") + + attributes = span["attributes"] + + assert SPANDATA.CODE_LINE_NUMBER in attributes + assert SPANDATA.CODE_NAMESPACE in attributes + assert SPANDATA.CODE_FILE_PATH in attributes + assert SPANDATA.CODE_FUNCTION in attributes + + assert type(attributes.get(SPANDATA.CODE_LINE_NUMBER)) == int + assert attributes.get(SPANDATA.CODE_LINE_NUMBER) > 0 + assert ( + attributes.get(SPANDATA.CODE_NAMESPACE) + == "tests.integrations.stdlib.test_httplib" + ) + assert attributes.get(SPANDATA.CODE_FILE_PATH).endswith( + "tests/integrations/stdlib/test_httplib.py" + ) - items = capture_items("event", "transaction", "span") + is_relative_path = attributes.get(SPANDATA.CODE_FILE_PATH)[0] != os.sep + assert is_relative_path - with mock.patch( - "sentry_sdk.integrations.stdlib.add_http_request_source", - add_http_request_source_with_pinned_timestamps, - ): - with sentry_sdk.traces.start_span(name="foo"): - conn = HTTPConnection("localhost", port=PORT) - conn.request("GET", "/foo") - conn.getresponse() + assert ( + attributes.get(SPANDATA.CODE_FUNCTION) + == "add_http_request_source_with_pinned_timestamps" + ) + else: + events = capture_events() - sentry_sdk.flush() - span = next(item.payload for item in items if item.type == "span") - assert span["name"].startswith("GET") + with mock.patch( + "sentry_sdk.integrations.stdlib.add_http_request_source", + add_http_request_source_with_pinned_timestamps, + ): + with start_transaction(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() - attributes = span["attributes"] + (event,) = events - assert SPANDATA.CODE_LINE_NUMBER in attributes - assert SPANDATA.CODE_NAMESPACE in attributes - assert SPANDATA.CODE_FILE_PATH in attributes - assert SPANDATA.CODE_FUNCTION in attributes + span = event["spans"][-1] + assert span["description"].startswith("GET") - assert type(attributes.get(SPANDATA.CODE_LINE_NUMBER)) == int - assert attributes.get(SPANDATA.CODE_LINE_NUMBER) > 0 - assert ( - attributes.get(SPANDATA.CODE_NAMESPACE) - == "tests.integrations.stdlib.test_httplib" - ) - assert attributes.get(SPANDATA.CODE_FILE_PATH).endswith( - "tests/integrations/stdlib/test_httplib.py" - ) + data = span.get("data", {}) - is_relative_path = attributes.get(SPANDATA.CODE_FILE_PATH)[0] != os.sep - assert is_relative_path + assert SPANDATA.CODE_LINENO in data + assert SPANDATA.CODE_NAMESPACE in data + assert SPANDATA.CODE_FILEPATH in data + assert SPANDATA.CODE_FUNCTION in data - assert ( - attributes.get(SPANDATA.CODE_FUNCTION) - == "add_http_request_source_with_pinned_timestamps" - ) + assert type(data.get(SPANDATA.CODE_LINENO)) == int + assert data.get(SPANDATA.CODE_LINENO) > 0 + assert ( + data.get(SPANDATA.CODE_NAMESPACE) + == "tests.integrations.stdlib.test_httplib" + ) + assert data.get(SPANDATA.CODE_FILEPATH).endswith( + "tests/integrations/stdlib/test_httplib.py" + ) + + is_relative_path = data.get(SPANDATA.CODE_FILEPATH)[0] != os.sep + assert is_relative_path + + assert ( + data.get(SPANDATA.CODE_FUNCTION) + == "add_http_request_source_with_pinned_timestamps" + ) @pytest.mark.parametrize("span_streaming", [True, False]) -def test_span_origin(sentry_init, capture_items, span_streaming): +def test_span_origin( + sentry_init, + capture_events, + capture_items, + span_streaming, +): sentry_init( traces_sample_rate=1.0, debug=True, _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - items = capture_items("event", "transaction", "span") - with sentry_sdk.traces.start_span(name="custom parent"): - conn = HTTPConnection("localhost", port=PORT) - conn.request("GET", "/foo") - conn.getresponse() + if span_streaming: + items = capture_items("span") + + with sentry_sdk.traces.start_span(name="custom parent"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() + + sentry_sdk.flush() + spans = [item.payload for item in items if item.type == "span"] + assert spans[1]["attributes"]["sentry.origin"] == "manual" + + assert spans[0]["attributes"]["sentry.op"] == "http.client" + assert spans[0]["attributes"]["sentry.origin"] == "auto.http.stdlib.httplib" + else: + sentry_init(traces_sample_rate=1.0, debug=True) + events = capture_events() + + with start_transaction(name="foo"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/foo") + conn.getresponse() - sentry_sdk.flush() - spans = [item.payload for item in items if item.type == "span"] - assert spans[1]["attributes"]["sentry.origin"] == "manual" + (event,) = events + assert event["contexts"]["trace"]["origin"] == "manual" - assert spans[0]["attributes"]["sentry.op"] == "http.client" - assert spans[0]["attributes"]["sentry.origin"] == "auto.http.stdlib.httplib" + assert event["spans"][0]["op"] == "http.client" + assert event["spans"][0]["origin"] == "auto.http.stdlib.httplib" @pytest.mark.parametrize("span_streaming", [True, False]) -def test_http_timeout(monkeypatch, sentry_init, capture_items, span_streaming): +def test_http_timeout( + monkeypatch, + sentry_init, + capture_envelopes, + capture_items, + span_streaming, +): mock_readinto = mock.Mock(side_effect=TimeoutError) monkeypatch.setattr(SocketIO, "readinto", mock_readinto) @@ -584,52 +907,95 @@ def test_http_timeout(monkeypatch, sentry_init, capture_items, span_streaming): _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - items = capture_items("event", "transaction", "span") + if span_streaming: + items = capture_items("span") + + with pytest.raises(TimeoutError): + with sentry_sdk.traces.start_span( + name="name", + attributes={ + "sentry.op": "op", + }, + ): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/bla") + conn.getresponse() + + sentry_sdk.flush() + spans = [item.payload for item in items if item.type == "span"] + span = spans[0] + assert span["attributes"]["sentry.op"] == "http.client" + assert span["name"] == f"GET http://localhost:{PORT}/bla" # noqa: E231 + else: + envelopes = capture_envelopes() - with pytest.raises(TimeoutError): - with sentry_sdk.traces.start_span( - name="name", - attributes={ - "sentry.op": "op", - }, - ): - conn = HTTPConnection("localhost", port=PORT) - conn.request("GET", "/bla") - conn.getresponse() + with pytest.raises(TimeoutError): + with start_transaction(op="op", name="name"): + conn = HTTPConnection("localhost", port=PORT) + conn.request("GET", "/bla") + conn.getresponse() + + (transaction_envelope,) = envelopes + transaction = transaction_envelope.get_transaction_event() + assert len(transaction["spans"]) == 1 - sentry_sdk.flush() - spans = [item.payload for item in items if item.type == "span"] - span = spans[0] - assert span["attributes"]["sentry.op"] == "http.client" - assert span["name"] == f"GET http://localhost:{PORT}/bla" # noqa: E231 + span = transaction["spans"][0] + assert span["op"] == "http.client" + assert span["description"] == f"GET http://localhost:{PORT}/bla" # noqa: E231 @pytest.mark.parametrize("tunnel_port", [8080, None]) @pytest.mark.parametrize("span_streaming", [True, False]) -def test_proxy_http_tunnel(sentry_init, capture_items, tunnel_port, span_streaming): - sentry_init( - traces_sample_rate=1.0, - _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, - ) - items = capture_items("event", "transaction", "span") - - with sentry_sdk.traces.start_span(name="custom parent"): - conn = HTTPConnection("localhost", PROXY_PORT) - conn.set_tunnel("api.example.com", tunnel_port) - conn.request("GET", "/foo") - conn.getresponse() - - sentry_sdk.flush() - spans = [item.payload for item in items if item.type == "span"] - (span,) = ( - span - for span in spans - if span["attributes"].get("sentry.origin") == "auto.http.stdlib.httplib" - ) +def test_proxy_http_tunnel( + sentry_init, + capture_events, + capture_items, + tunnel_port, + span_streaming, +): + if span_streaming: + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) + items = capture_items("span") + + with sentry_sdk.traces.start_span(name="custom parent"): + conn = HTTPConnection("localhost", PROXY_PORT) + conn.set_tunnel("api.example.com", tunnel_port) + conn.request("GET", "/foo") + conn.getresponse() + + sentry_sdk.flush() + spans = [item.payload for item in items if item.type == "span"] + (span,) = ( + span + for span in spans + if span["attributes"].get("sentry.origin") == "auto.http.stdlib.httplib" + ) + + port_modifier = f":{tunnel_port}" if tunnel_port else "" + assert span["name"] == f"GET http://api.example.com{port_modifier}/foo" + assert span["attributes"]["url"] == f"http://api.example.com{port_modifier}/foo" + assert span["attributes"][SPANDATA.HTTP_METHOD] == "GET" + assert span["attributes"][SPANDATA.NETWORK_PEER_ADDRESS] == "localhost" + assert span["attributes"][SPANDATA.NETWORK_PEER_PORT] == PROXY_PORT + else: + sentry_init(traces_sample_rate=1.0) + events = capture_events() + + with start_transaction(name="test_transaction"): + conn = HTTPConnection("localhost", PROXY_PORT) + conn.set_tunnel("api.example.com", tunnel_port) + conn.request("GET", "/foo") + conn.getresponse() + + (event,) = events + (span,) = event["spans"] - port_modifier = f":{tunnel_port}" if tunnel_port else "" - assert span["name"] == f"GET http://api.example.com{port_modifier}/foo" - assert span["attributes"]["url"] == f"http://api.example.com{port_modifier}/foo" - assert span["attributes"][SPANDATA.HTTP_METHOD] == "GET" - assert span["attributes"][SPANDATA.NETWORK_PEER_ADDRESS] == "localhost" - assert span["attributes"][SPANDATA.NETWORK_PEER_PORT] == PROXY_PORT + port_modifier = f":{tunnel_port}" if tunnel_port else "" + assert span["description"] == f"GET http://api.example.com{port_modifier}/foo" + assert span["data"]["url"] == f"http://api.example.com{port_modifier}/foo" + assert span["data"][SPANDATA.HTTP_METHOD] == "GET" + assert span["data"][SPANDATA.NETWORK_PEER_ADDRESS] == "localhost" + assert span["data"][SPANDATA.NETWORK_PEER_PORT] == PROXY_PORT From debbdda49c5888dfe22c816ed7860bf7e38dfe3c Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 14:46:25 +0200 Subject: [PATCH 10/26] cleanup --- tests/integrations/stdlib/test_httplib.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 11b4d06154..f8d35cae79 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -953,11 +953,12 @@ def test_proxy_http_tunnel( tunnel_port, span_streaming, ): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) + if span_streaming: - sentry_init( - traces_sample_rate=1.0, - _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, - ) items = capture_items("span") with sentry_sdk.traces.start_span(name="custom parent"): @@ -981,7 +982,6 @@ def test_proxy_http_tunnel( assert span["attributes"][SPANDATA.NETWORK_PEER_ADDRESS] == "localhost" assert span["attributes"][SPANDATA.NETWORK_PEER_PORT] == PROXY_PORT else: - sentry_init(traces_sample_rate=1.0) events = capture_events() with start_transaction(name="test_transaction"): From b15b7b09af17d593fe629fb3109fb00d15c21333 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 15:35:28 +0200 Subject: [PATCH 11/26] adjust outgoing trace test --- tests/integrations/stdlib/test_httplib.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index f8d35cae79..df26b282fc 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -192,15 +192,17 @@ def getresponse(self, *args, **kwargs): if span_streaming: items = capture_items("span") + sentry_sdk.traces.continue_trace({}) + with mock.patch( "sentry_sdk.tracing_utils.Random.randrange", return_value=250000 ): - sentry_sdk.traces.continue_trace({}) - - with sentry_sdk.traces.start_span(name="Head SDK tx"): - connection = HTTPSConnectionRecordingRequestHeaders("localhost", port=PORT) - connection.request("GET", "/top-chasers") - connection.getresponse() + with sentry_sdk.traces.start_span(name="Head SDK tx"): + connection = HTTPSConnectionRecordingRequestHeaders( + "localhost", port=PORT + ) + connection.request("GET", "/top-chasers") + connection.getresponse() sentry_sdk.flush() request_span = next(item.payload for item in items if item.type == "span") @@ -215,8 +217,9 @@ def getresponse(self, *args, **kwargs): "sentry-sample_rand=0.250000," "sentry-environment=production," "sentry-release=foo," + "sentry-transaction=Head%%20SDK%%20tx," "sentry-sample_rate=0.5," - "sentry-sampled=%s" + "sentry-sampled=true" ) % request_span["trace_id"] else: events = capture_events() From 72b7c62c1ed68965777912e0e4dff3b3955abc17 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 17:13:57 +0200 Subject: [PATCH 12/26] subprocess tests --- tests/integrations/stdlib/test_subprocess.py | 475 +++++++++++++------ 1 file changed, 332 insertions(+), 143 deletions(-) diff --git a/tests/integrations/stdlib/test_subprocess.py b/tests/integrations/stdlib/test_subprocess.py index 593ef8a0dc..8938db4211 100644 --- a/tests/integrations/stdlib/test_subprocess.py +++ b/tests/integrations/stdlib/test_subprocess.py @@ -1,4 +1,5 @@ import os +import sentry_sdk import platform import subprocess import sys @@ -7,6 +8,7 @@ import pytest from sentry_sdk import capture_message, start_transaction +from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations.stdlib import StdlibIntegration from tests.conftest import ApproxDict @@ -42,153 +44,291 @@ def __len__(self): ) @pytest.mark.parametrize("env_mapping", [None, os.environ, ImmutableDict(os.environ)]) @pytest.mark.parametrize("with_cwd", [True, False]) +@pytest.mark.parametrize("span_streaming", [True, False]) def test_subprocess_basic( sentry_init, capture_events, + capture_items, monkeypatch, positional_args, iterator, env_mapping, with_cwd, + span_streaming, ): monkeypatch.setenv("FOO", "bar") old_environ = dict(os.environ) - sentry_init(integrations=[StdlibIntegration()], traces_sample_rate=1.0) - events = capture_events() - - with start_transaction(name="foo") as transaction: - args = [ - sys.executable, - "-c", - "import os; " - "import sentry_sdk; " - "from sentry_sdk.integrations.stdlib import get_subprocess_traceparent_headers; " - "sentry_sdk.init(); " - "assert os.environ['FOO'] == 'bar'; " - "print(dict(get_subprocess_traceparent_headers()))", - ] + sentry_init( + integrations=[StdlibIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) + if span_streaming: + items = capture_items("event", "transaction", "span") + + with sentry_sdk.traces.start_span(name="custom parent") as span: + args = [ + sys.executable, + "-c", + "import os; " + "import sentry_sdk; " + "from sentry_sdk.integrations.stdlib import get_subprocess_traceparent_headers; " + "sentry_sdk.init(); " + "assert os.environ['FOO'] == 'bar'; " + "print(dict(get_subprocess_traceparent_headers()))", + ] + + if iterator: + args = iter(args) + + if positional_args: + a = ( + args, + 0, # bufsize + None, # executable + None, # stdin + subprocess.PIPE, # stdout + None, # stderr + None, # preexec_fn + False, # close_fds + False, # shell + os.getcwd() if with_cwd else None, # cwd + ) + + if env_mapping is not None: + a += (env_mapping,) + + popen = subprocess.Popen(*a) + + else: + kw = {"args": args, "stdout": subprocess.PIPE} + + if with_cwd: + kw["cwd"] = os.getcwd() + + if env_mapping is not None: + kw["env"] = env_mapping + + popen = subprocess.Popen(**kw) + + output, unused_err = popen.communicate() + retcode = popen.poll() + assert not retcode + + assert os.environ == old_environ + + assert span.trace_id in str(output) + + capture_message("hi") + + (message_event,) = (item.payload for item in items if item.type == "event") + assert message_event["message"] == "hi" + + data = ApproxDict({}) + + sentry_sdk.flush() + ( + subprocess_init_span, + subprocess_wait_span, + subprocess_communicate_span, + parent_span, + ) = (item.payload for item in items if item.type == "span") + + assert ( + subprocess_init_span["attributes"]["sentry.op"], + subprocess_wait_span["attributes"]["sentry.op"], + subprocess_communicate_span["attributes"]["sentry.op"], + ) == ("subprocess", "subprocess.wait", "subprocess.communicate") + + # span hierarchy + assert ( + subprocess_wait_span["parent_span_id"] + == subprocess_communicate_span["span_id"] + ) + + assert ( + subprocess_communicate_span["parent_span_id"] + == subprocess_init_span["parent_span_id"] + == parent_span["span_id"] + ) + + assert ( + subprocess_init_span["attributes"][SPANDATA.PROCESS_PID] + == subprocess_wait_span["attributes"][SPANDATA.PROCESS_PID] + == subprocess_communicate_span["attributes"][SPANDATA.PROCESS_PID] + ) + + # data of init span + assert subprocess_init_span.get("attributes", {}) == data if iterator: - args = iter(args) - - if positional_args: - a = ( - args, - 0, # bufsize - None, # executable - None, # stdin - subprocess.PIPE, # stdout - None, # stderr - None, # preexec_fn - False, # close_fds - False, # shell - os.getcwd() if with_cwd else None, # cwd - ) - - if env_mapping is not None: - a += (env_mapping,) - - popen = subprocess.Popen(*a) - + assert "iterator" in subprocess_init_span["name"] + assert subprocess_init_span["name"].startswith("<") else: - kw = {"args": args, "stdout": subprocess.PIPE} - - if with_cwd: - kw["cwd"] = os.getcwd() - - if env_mapping is not None: - kw["env"] = env_mapping - - popen = subprocess.Popen(**kw) - - output, unused_err = popen.communicate() - retcode = popen.poll() - assert not retcode - - assert os.environ == old_environ - - assert transaction.trace_id in str(output) - - capture_message("hi") + assert sys.executable + " -c" in subprocess_init_span["name"] - ( - transaction_event, - message_event, - ) = events - - assert message_event["message"] == "hi" - - data = ApproxDict({"subprocess.cwd": os.getcwd()} if with_cwd else {}) - - (crumb,) = message_event["breadcrumbs"]["values"] - assert crumb == { - "category": "subprocess", - "data": data, - "message": crumb["message"], - "timestamp": crumb["timestamp"], - "type": "subprocess", - } - - if not iterator: - assert crumb["message"].startswith(sys.executable + " ") - - assert transaction_event["type"] == "transaction" - - ( - subprocess_init_span, - subprocess_communicate_span, - subprocess_wait_span, - ) = transaction_event["spans"] - - assert ( - subprocess_init_span["op"], - subprocess_communicate_span["op"], - subprocess_wait_span["op"], - ) == ("subprocess", "subprocess.communicate", "subprocess.wait") + else: + events = capture_events() + + with start_transaction(name="foo") as transaction: + args = [ + sys.executable, + "-c", + "import os; " + "import sentry_sdk; " + "from sentry_sdk.integrations.stdlib import get_subprocess_traceparent_headers; " + "sentry_sdk.init(); " + "assert os.environ['FOO'] == 'bar'; " + "print(dict(get_subprocess_traceparent_headers()))", + ] + + if iterator: + args = iter(args) + + if positional_args: + a = ( + args, + 0, # bufsize + None, # executable + None, # stdin + subprocess.PIPE, # stdout + None, # stderr + None, # preexec_fn + False, # close_fds + False, # shell + os.getcwd() if with_cwd else None, # cwd + ) + + if env_mapping is not None: + a += (env_mapping,) + + popen = subprocess.Popen(*a) + + else: + kw = {"args": args, "stdout": subprocess.PIPE} + + if with_cwd: + kw["cwd"] = os.getcwd() + + if env_mapping is not None: + kw["env"] = env_mapping + + popen = subprocess.Popen(**kw) + + output, unused_err = popen.communicate() + retcode = popen.poll() + assert not retcode + + assert os.environ == old_environ + + assert transaction.trace_id in str(output) + + capture_message("hi") + + ( + transaction_event, + message_event, + ) = events + + assert message_event["message"] == "hi" + + data = ApproxDict({"subprocess.cwd": os.getcwd()} if with_cwd else {}) + + (crumb,) = message_event["breadcrumbs"]["values"] + assert crumb == { + "category": "subprocess", + "data": data, + "message": crumb["message"], + "timestamp": crumb["timestamp"], + "type": "subprocess", + } + + if not iterator: + assert crumb["message"].startswith(sys.executable + " ") + + assert transaction_event["type"] == "transaction" + + ( + subprocess_init_span, + subprocess_communicate_span, + subprocess_wait_span, + ) = transaction_event["spans"] + + assert ( + subprocess_init_span["op"], + subprocess_communicate_span["op"], + subprocess_wait_span["op"], + ) == ("subprocess", "subprocess.communicate", "subprocess.wait") + + assert ( + subprocess_wait_span["parent_span_id"] + == subprocess_communicate_span["span_id"] + ) + + assert ( + subprocess_communicate_span["parent_span_id"] + == subprocess_init_span["parent_span_id"] + == transaction_event["contexts"]["trace"]["span_id"] + ) + + assert ( + subprocess_init_span["tags"]["subprocess.pid"] + == subprocess_wait_span["tags"]["subprocess.pid"] + == subprocess_communicate_span["tags"]["subprocess.pid"] + ) + + # data of init span + assert subprocess_init_span.get("data", {}) == data + if iterator: + assert "iterator" in subprocess_init_span["description"] + assert subprocess_init_span["description"].startswith("<") + else: + assert sys.executable + " -c" in subprocess_init_span["description"] - # span hierarchy - assert ( - subprocess_wait_span["parent_span_id"] == subprocess_communicate_span["span_id"] - ) - assert ( - subprocess_communicate_span["parent_span_id"] - == subprocess_init_span["parent_span_id"] - == transaction_event["contexts"]["trace"]["span_id"] - ) - # common data - assert ( - subprocess_init_span["tags"]["subprocess.pid"] - == subprocess_wait_span["tags"]["subprocess.pid"] - == subprocess_communicate_span["tags"]["subprocess.pid"] +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_subprocess_empty_env( + sentry_init, + monkeypatch, + span_streaming, +): + monkeypatch.setenv("TEST_MARKER", "should_not_be_seen") + sentry_init( + integrations=[StdlibIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - - # data of init span - assert subprocess_init_span.get("data", {}) == data - if iterator: - assert "iterator" in subprocess_init_span["description"] - assert subprocess_init_span["description"].startswith("<") + if span_streaming: + with sentry_sdk.traces.start_span(name="custom parent"): + args = [ + sys.executable, + "-c", + "import os; print(os.environ.get('TEST_MARKER', None))", + ] + output = subprocess.check_output(args, env={}, universal_newlines=True) else: - assert sys.executable + " -c" in subprocess_init_span["description"] + with start_transaction(name="foo"): + args = [ + sys.executable, + "-c", + "import os; print(os.environ.get('TEST_MARKER', None))", + ] + output = subprocess.check_output(args, env={}, universal_newlines=True) - -def test_subprocess_empty_env(sentry_init, monkeypatch): - monkeypatch.setenv("TEST_MARKER", "should_not_be_seen") - sentry_init(integrations=[StdlibIntegration()], traces_sample_rate=1.0) - with start_transaction(name="foo"): - args = [ - sys.executable, - "-c", - "import os; print(os.environ.get('TEST_MARKER', None))", - ] - output = subprocess.check_output(args, env={}, universal_newlines=True) assert "should_not_be_seen" not in output -def test_subprocess_invalid_args(sentry_init): - sentry_init(integrations=[StdlibIntegration()]) +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_subprocess_invalid_args( + sentry_init, + span_streaming, +): + sentry_init( + integrations=[StdlibIntegration()], + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) with pytest.raises(TypeError) as excinfo: subprocess.Popen(1) @@ -196,31 +336,80 @@ def test_subprocess_invalid_args(sentry_init): assert "'int' object is not iterable" in str(excinfo.value) -def test_subprocess_span_origin(sentry_init, capture_events): - sentry_init(integrations=[StdlibIntegration()], traces_sample_rate=1.0) - events = capture_events() +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_subprocess_span_origin( + sentry_init, + capture_events, + capture_items, + span_streaming, +): + sentry_init( + integrations=[StdlibIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) + + if span_streaming: + items = capture_items("event", "transaction", "span") - with start_transaction(name="foo"): - args = [ - sys.executable, - "-c", - "print('hello world')", - ] - kw = {"args": args, "stdout": subprocess.PIPE} + with sentry_sdk.traces.start_span(name="custom parent"): + args = [ + sys.executable, + "-c", + "print('hello world')", + ] + kw = {"args": args, "stdout": subprocess.PIPE} - popen = subprocess.Popen(**kw) - popen.communicate() - popen.poll() + popen = subprocess.Popen(**kw) + popen.communicate() + popen.poll() + + sentry_sdk.flush() + spans = [item.payload for item in items if item.type == "span"] + + assert spans[3]["attributes"]["sentry.origin"] == "manual" + + assert spans[0]["attributes"]["sentry.op"] == "subprocess" + assert ( + spans[0]["attributes"]["sentry.origin"] + == "auto.subprocess.stdlib.subprocess" + ) + + assert spans[1]["attributes"]["sentry.op"] == "subprocess.wait" + assert ( + spans[1]["attributes"]["sentry.origin"] + == "auto.subprocess.stdlib.subprocess" + ) + + assert spans[2]["attributes"]["sentry.op"] == "subprocess.communicate" + assert ( + spans[2]["attributes"]["sentry.origin"] + == "auto.subprocess.stdlib.subprocess" + ) + else: + events = capture_events() + + with start_transaction(name="foo"): + args = [ + sys.executable, + "-c", + "print('hello world')", + ] + kw = {"args": args, "stdout": subprocess.PIPE} + + popen = subprocess.Popen(**kw) + popen.communicate() + popen.poll() - (event,) = events + (event,) = events - assert event["contexts"]["trace"]["origin"] == "manual" + assert event["contexts"]["trace"]["origin"] == "manual" - assert event["spans"][0]["op"] == "subprocess" - assert event["spans"][0]["origin"] == "auto.subprocess.stdlib.subprocess" + assert event["spans"][0]["op"] == "subprocess" + assert event["spans"][0]["origin"] == "auto.subprocess.stdlib.subprocess" - assert event["spans"][1]["op"] == "subprocess.communicate" - assert event["spans"][1]["origin"] == "auto.subprocess.stdlib.subprocess" + assert event["spans"][1]["op"] == "subprocess.communicate" + assert event["spans"][1]["origin"] == "auto.subprocess.stdlib.subprocess" - assert event["spans"][2]["op"] == "subprocess.wait" - assert event["spans"][2]["origin"] == "auto.subprocess.stdlib.subprocess" + assert event["spans"][2]["op"] == "subprocess.wait" + assert event["spans"][2]["origin"] == "auto.subprocess.stdlib.subprocess" From c39c5494fc9e31fc2975988e91547b86393f043a Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 17:14:43 +0200 Subject: [PATCH 13/26] stdlib integration --- sentry_sdk/integrations/stdlib.py | 66 ++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 8baa3659b8..59bcd2c549 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -254,10 +254,12 @@ def sentry_patched_popen_init( span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) span: "Union[Span, StreamedSpan]" if span_streaming: - span = sentry_sdk.start_span( - op=OP.SUBPROCESS, - name=description, - origin="auto.subprocess.stdlib.subprocess", + span = sentry_sdk.traces.start_span( + name=OP.SUBPROCESS if description is None else description, + attributes={ + "sentry.op": OP.SUBPROCESS, + "sentry.origin": "auto.subprocess.stdlib.subprocess", + }, ) else: span = sentry_sdk.start_span( @@ -280,12 +282,16 @@ def sentry_patched_popen_init( ) env["SUBPROCESS_" + k.upper().replace("-", "_")] = v - if cwd: + if cwd and isinstance(span, Span): span.set_data("subprocess.cwd", cwd) rv = old_popen_init(self, *a, **kw) - span.set_tag("subprocess.pid", self.pid) + if isinstance(span, Span): + span.set_tag("subprocess.pid", self.pid) + else: + span.set_attribute(SPANDATA.PROCESS_PID, self.pid) + return rv subprocess.Popen.__init__ = sentry_patched_popen_init # type: ignore @@ -296,12 +302,24 @@ def sentry_patched_popen_init( def sentry_patched_popen_wait( self: "subprocess.Popen[Any]", *a: "Any", **kw: "Any" ) -> "Any": - with sentry_sdk.start_span( - op=OP.SUBPROCESS_WAIT, - origin="auto.subprocess.stdlib.subprocess", - ) as span: - span.set_tag("subprocess.pid", self.pid) - return old_popen_wait(self, *a, **kw) + span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) + if span_streaming: + with sentry_sdk.traces.start_span( + name=OP.SUBPROCESS_COMMUNICATE, + attributes={ + "sentry.op": OP.SUBPROCESS_WAIT, + "sentry.origin": "auto.subprocess.stdlib.subprocess", + }, + ) as span: + span.set_attribute(SPANDATA.PROCESS_PID, self.pid) + return old_popen_wait(self, *a, **kw) + else: + with sentry_sdk.start_span( + op=OP.SUBPROCESS_WAIT, + origin="auto.subprocess.stdlib.subprocess", + ) as span: + span.set_tag("subprocess.pid", self.pid) + return old_popen_wait(self, *a, **kw) subprocess.Popen.wait = sentry_patched_popen_wait # type: ignore @@ -311,12 +329,24 @@ def sentry_patched_popen_wait( def sentry_patched_popen_communicate( self: "subprocess.Popen[Any]", *a: "Any", **kw: "Any" ) -> "Any": - with sentry_sdk.start_span( - op=OP.SUBPROCESS_COMMUNICATE, - origin="auto.subprocess.stdlib.subprocess", - ) as span: - span.set_tag("subprocess.pid", self.pid) - return old_popen_communicate(self, *a, **kw) + span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) + if span_streaming: + with sentry_sdk.traces.start_span( + name=OP.SUBPROCESS_COMMUNICATE, + attributes={ + "sentry.op": OP.SUBPROCESS_COMMUNICATE, + "sentry.origin": "auto.subprocess.stdlib.subprocess", + }, + ) as span: + span.set_attribute(SPANDATA.PROCESS_PID, self.pid) + return old_popen_communicate(self, *a, **kw) + else: + with sentry_sdk.start_span( + op=OP.SUBPROCESS_COMMUNICATE, + origin="auto.subprocess.stdlib.subprocess", + ) as span: + span.set_tag("subprocess.pid", self.pid) + return old_popen_communicate(self, *a, **kw) subprocess.Popen.communicate = sentry_patched_popen_communicate # type: ignore From a0c8bc796eec138d864e008e40966602da34c2a4 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 17:28:07 +0200 Subject: [PATCH 14/26] use documented url attributes --- sentry_sdk/consts.py | 24 +++++++++++++++++++++++ sentry_sdk/integrations/stdlib.py | 21 +++++++++++++------- tests/integrations/stdlib/test_httplib.py | 5 ++++- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 26fbbfcdcd..c05b0d01a9 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -881,6 +881,12 @@ class SPANDATA: Example: "tcp", "udp", "unix" """ + PROCESS_PID = "process.pid" + """ + The process ID of the running process. + Example: 12345 + """ + PROFILER_ID = "profiler_id" """ Label identifying the profiler id that the span occurred in. This should be a string. @@ -924,6 +930,24 @@ class SPANDATA: Example: "MainThread" """ + URL_FULL = "url.full" + """ + The URL of the resource that was fetched. + Example: "https://example.com/test?foo=bar#buzz" + """ + + URL_FRAGMENT = "url.fragment" + """ + The fragments present in the URI. Note that this does not contain the leading # character, while the `http.fragment` attribute does. + Example: "details" + """ + + URL_QUERY = "url.query" + """ + The query string present in the URL. Note that this does not contain the leading ? character, while the `http.query` attribute does. + Example: "foo=bar&bar=baz" + """ + MCP_TOOL_NAME = "mcp.tool.name" """ The name of the MCP tool being called. diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 59bcd2c549..9eae10ddf1 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -104,7 +104,6 @@ def putrequest( parsed_url = parse_url(real_url, sanitize=False) span_streaming = has_span_streaming_enabled(client.options) - span: "Union[Span, StreamedSpan]" if span_streaming: span = sentry_sdk.traces.start_span( name="%s %s" @@ -114,6 +113,13 @@ def putrequest( "sentry.op": OP.HTTP_CLIENT, }, ) + + span.set_attribute(SPANDATA.HTTP_METHOD, method) + if parsed_url is not None: + span.set_attribute(SPANDATA.URL_FULL, parsed_url.url) + span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query) + span.set_attribute(SPANDATA.URL_FRAGMENT, parsed_url.fragment) + set_on_span = span.set_attribute else: @@ -123,13 +129,14 @@ def putrequest( % (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE), origin="auto.http.stdlib.httplib", ) - set_on_span = span.set_data - set_on_span(SPANDATA.HTTP_METHOD, method) - if parsed_url is not None: - set_on_span("url", parsed_url.url) - set_on_span(SPANDATA.HTTP_QUERY, parsed_url.query) - set_on_span(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + span.set_data(SPANDATA.HTTP_METHOD, method) + if parsed_url is not None: + span.set_data("url", parsed_url.url) + span.set_data(SPANDATA.HTTP_QUERY, parsed_url.query) + span.set_data(SPANDATA.HTTP_FRAGMENT, parsed_url.fragment) + + set_on_span = span.set_data # for proxies, these point to the proxy host/port if tunnel_host: diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index df26b282fc..870e718ea7 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -980,7 +980,10 @@ def test_proxy_http_tunnel( port_modifier = f":{tunnel_port}" if tunnel_port else "" assert span["name"] == f"GET http://api.example.com{port_modifier}/foo" - assert span["attributes"]["url"] == f"http://api.example.com{port_modifier}/foo" + assert ( + span["attributes"][SPANDATA.URL_FULL] + == f"http://api.example.com{port_modifier}/foo" + ) assert span["attributes"][SPANDATA.HTTP_METHOD] == "GET" assert span["attributes"][SPANDATA.NETWORK_PEER_ADDRESS] == "localhost" assert span["attributes"][SPANDATA.NETWORK_PEER_PORT] == PROXY_PORT From d688f0168133be004e8da3fa08f511b7c61e7cde Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 17:42:01 +0200 Subject: [PATCH 15/26] add type hint --- sentry_sdk/integrations/stdlib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 9eae10ddf1..07f93e8587 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -104,6 +104,7 @@ def putrequest( parsed_url = parse_url(real_url, sanitize=False) span_streaming = has_span_streaming_enabled(client.options) + span: "Union[Span, StreamedSpan]" if span_streaming: span = sentry_sdk.traces.start_span( name="%s %s" From 50fe59b92a31a1179ca8262a8b584dcd739ea02e Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 17:47:48 +0200 Subject: [PATCH 16/26] use http.request.method --- tests/integrations/stdlib/test_httplib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 870e718ea7..8bd4e053f1 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -984,7 +984,7 @@ def test_proxy_http_tunnel( span["attributes"][SPANDATA.URL_FULL] == f"http://api.example.com{port_modifier}/foo" ) - assert span["attributes"][SPANDATA.HTTP_METHOD] == "GET" + assert span["attributes"][SPANDATA.HTTP_REQUEST_METHOD] == "GET" assert span["attributes"][SPANDATA.NETWORK_PEER_ADDRESS] == "localhost" assert span["attributes"][SPANDATA.NETWORK_PEER_PORT] == PROXY_PORT else: From 1cbd5e14ba14c8bb8a3aa8ca2af3c19572f0a115 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Tue, 28 Apr 2026 18:10:51 +0200 Subject: [PATCH 17/26] update --- sentry_sdk/integrations/stdlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 07f93e8587..033851a38c 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -112,10 +112,10 @@ def putrequest( attributes={ "sentry.origin": "auto.http.stdlib.httplib", "sentry.op": OP.HTTP_CLIENT, + SPANDATA.HTTP_REQUEST_METHOD: method, }, ) - span.set_attribute(SPANDATA.HTTP_METHOD, method) if parsed_url is not None: span.set_attribute(SPANDATA.URL_FULL, parsed_url.url) span.set_attribute(SPANDATA.URL_QUERY, parsed_url.query) From ef94687aa3894e64996cc7a26889d7541ea59af2 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 29 Apr 2026 08:04:42 +0200 Subject: [PATCH 18/26] flip order in if-elif --- sentry_sdk/integrations/stdlib.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 033851a38c..60c7108950 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -295,10 +295,10 @@ def sentry_patched_popen_init( rv = old_popen_init(self, *a, **kw) - if isinstance(span, Span): - span.set_tag("subprocess.pid", self.pid) - else: + if isinstance(span, StreamedSpan): span.set_attribute(SPANDATA.PROCESS_PID, self.pid) + else: + span.set_tag("subprocess.pid", self.pid) return rv From 346d12431c76029ed729350d33b9b9c5a17ab86e Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 29 Apr 2026 08:13:07 +0200 Subject: [PATCH 19/26] add tests again --- tests/integrations/stdlib/test_httplib.py | 156 +++++++++++++++++++++- 1 file changed, 154 insertions(+), 2 deletions(-) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 8bd4e053f1..a1748a0664 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -5,15 +5,18 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from socket import SocketIO from threading import Thread +from urllib.error import HTTPError +from urllib.request import urlopen from unittest import mock import pytest import sentry_sdk -from sentry_sdk import start_transaction, continue_trace +from sentry_sdk import capture_message, start_transaction, continue_trace from sentry_sdk.consts import MATCH_ALL, SPANDATA +from sentry_sdk.integrations.stdlib import StdlibIntegration -from tests.conftest import create_mock_http_server, get_free_port +from tests.conftest import ApproxDict, create_mock_http_server, get_free_port PORT = create_mock_http_server() @@ -42,6 +45,106 @@ def create_mock_proxy_server(): PROXY_PORT = create_mock_proxy_server() +def test_crumb_capture(sentry_init, capture_events): + sentry_init(integrations=[StdlibIntegration()]) + events = capture_events() + + url = "http://localhost:{}/some/random/url".format(PORT) + urlopen(url) + + capture_message("Testing!") + + (event,) = events + (crumb,) = event["breadcrumbs"]["values"] + + assert crumb["type"] == "http" + assert crumb["category"] == "httplib" + assert crumb["data"] == ApproxDict( + { + "url": url, + SPANDATA.HTTP_METHOD: "GET", + SPANDATA.HTTP_STATUS_CODE: 200, + "reason": "OK", + SPANDATA.HTTP_FRAGMENT: "", + SPANDATA.HTTP_QUERY: "", + } + ) + + +@pytest.mark.parametrize( + "status_code,level", + [ + (200, None), + (301, None), + (403, "warning"), + (405, "warning"), + (500, "error"), + ], +) +def test_crumb_capture_client_error(sentry_init, capture_events, status_code, level): + sentry_init(integrations=[StdlibIntegration()]) + events = capture_events() + + url = f"http://localhost:{PORT}/status/{status_code}" # noqa:E231 + try: + urlopen(url) + except HTTPError: + pass + + capture_message("Testing!") + + (event,) = events + (crumb,) = event["breadcrumbs"]["values"] + + assert crumb["type"] == "http" + assert crumb["category"] == "httplib" + + if level is None: + assert "level" not in crumb + else: + assert crumb["level"] == level + + assert crumb["data"] == ApproxDict( + { + "url": url, + SPANDATA.HTTP_METHOD: "GET", + SPANDATA.HTTP_STATUS_CODE: status_code, + SPANDATA.HTTP_FRAGMENT: "", + SPANDATA.HTTP_QUERY: "", + } + ) + + +def test_crumb_capture_hint(sentry_init, capture_events): + def before_breadcrumb(crumb, hint): + crumb["data"]["extra"] = "foo" + return crumb + + sentry_init(integrations=[StdlibIntegration()], before_breadcrumb=before_breadcrumb) + events = capture_events() + + url = "http://localhost:{}/some/random/url".format(PORT) + urlopen(url) + + capture_message("Testing!") + + (event,) = events + (crumb,) = event["breadcrumbs"]["values"] + assert crumb["type"] == "http" + assert crumb["category"] == "httplib" + assert crumb["data"] == ApproxDict( + { + "url": url, + SPANDATA.HTTP_METHOD: "GET", + SPANDATA.HTTP_STATUS_CODE: 200, + "reason": "OK", + "extra": "foo", + SPANDATA.HTTP_FRAGMENT: "", + SPANDATA.HTTP_QUERY: "", + } + ) + + @pytest.mark.parametrize("span_streaming", [True, False]) def test_empty_realurl( sentry_init, @@ -59,6 +162,55 @@ def test_empty_realurl( HTTPConnection("localhost", port=PORT).putrequest("POST", None) +def test_httplib_misuse(sentry_init, capture_events, request): + """HTTPConnection.getresponse must be called after every call to + HTTPConnection.request. However, if somebody does not abide by + this contract, we still should handle this gracefully and not + send mixed breadcrumbs. + + Test whether our breadcrumbs are coherent when somebody uses HTTPConnection + wrongly. + """ + + sentry_init() + events = capture_events() + + conn = HTTPConnection("localhost", PORT) + + # make sure we release the resource, even if the test fails + request.addfinalizer(conn.close) + + conn.request("GET", "/200") + + with pytest.raises(Exception): # noqa: B017 + # This raises an exception, because we didn't call `getresponse` for + # the previous request yet. + # + # This call should not affect our breadcrumb. + conn.request("POST", "/200") + + response = conn.getresponse() + assert response._method == "GET" + + capture_message("Testing!") + + (event,) = events + (crumb,) = event["breadcrumbs"]["values"] + + assert crumb["type"] == "http" + assert crumb["category"] == "httplib" + assert crumb["data"] == ApproxDict( + { + "url": "http://localhost:{}/200".format(PORT), + SPANDATA.HTTP_METHOD: "GET", + SPANDATA.HTTP_STATUS_CODE: 200, + "reason": "OK", + SPANDATA.HTTP_FRAGMENT: "", + SPANDATA.HTTP_QUERY: "", + } + ) + + @pytest.mark.parametrize("span_streaming", [True, False]) def test_outgoing_trace_headers( sentry_init, From 9a54013636d509a25761aba1130ea1d27cdc12f9 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 29 Apr 2026 08:16:33 +0200 Subject: [PATCH 20/26] add len assertion --- tests/integrations/stdlib/test_httplib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index a1748a0664..313151af5e 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -1078,6 +1078,7 @@ def test_http_timeout( sentry_sdk.flush() spans = [item.payload for item in items if item.type == "span"] + assert len(spans) == 2 span = spans[0] assert span["attributes"]["sentry.op"] == "http.client" assert span["name"] == f"GET http://localhost:{PORT}/bla" # noqa: E231 From 224d4a4e4cb2298fea5e0de0c9f057956f08c54c Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 29 Apr 2026 08:21:52 +0200 Subject: [PATCH 21/26] update span name --- sentry_sdk/integrations/stdlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 60c7108950..700d57fa2f 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -313,7 +313,7 @@ def sentry_patched_popen_wait( span_streaming = has_span_streaming_enabled(sentry_sdk.get_client().options) if span_streaming: with sentry_sdk.traces.start_span( - name=OP.SUBPROCESS_COMMUNICATE, + name=OP.SUBPROCESS_WAIT, attributes={ "sentry.op": OP.SUBPROCESS_WAIT, "sentry.origin": "auto.subprocess.stdlib.subprocess", From 1106fe44bbfefd6ca21f4b31a3503d47f7c888a8 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 29 Apr 2026 08:23:19 +0200 Subject: [PATCH 22/26] use non-deprecated function --- sentry_sdk/integrations/stdlib.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 700d57fa2f..f779494707 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -181,7 +181,10 @@ def getresponse(self: "HTTPConnection", *args: "Any", **kwargs: "Any") -> "Any": span.set_http_status(int(rv.status)) span.set_data("reason", rv.reason) finally: - span.finish() + if isinstance(span, StreamedSpan): + span.end() + else: + span.finish() with capture_internal_exceptions(): add_http_request_source(span) From 5c0a8751543114c3bacdafba52da3fceb6359fa1 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 29 Apr 2026 08:33:05 +0200 Subject: [PATCH 23/26] set request source attributes before finishing span --- sentry_sdk/integrations/stdlib.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index f779494707..04043ef16a 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -182,12 +182,14 @@ def getresponse(self: "HTTPConnection", *args: "Any", **kwargs: "Any") -> "Any": span.set_data("reason", rv.reason) finally: if isinstance(span, StreamedSpan): + with capture_internal_exceptions(): + add_http_request_source(span) span.end() else: span.finish() - with capture_internal_exceptions(): - add_http_request_source(span) + with capture_internal_exceptions(): + add_http_request_source(span) return rv From 771e846374bcaf41cd232f7fde45f4d686dcf3e2 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 29 Apr 2026 08:33:23 +0200 Subject: [PATCH 24/26] remove double init --- tests/integrations/stdlib/test_httplib.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 313151af5e..763b527045 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -1031,7 +1031,6 @@ def test_span_origin( assert spans[0]["attributes"]["sentry.op"] == "http.client" assert spans[0]["attributes"]["sentry.origin"] == "auto.http.stdlib.httplib" else: - sentry_init(traces_sample_rate=1.0, debug=True) events = capture_events() with start_transaction(name="foo"): From f64437cba5f96ba53cb41e16c67b11d38550ab77 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 29 Apr 2026 08:37:09 +0200 Subject: [PATCH 25/26] update description --- sentry_sdk/integrations/stdlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 04043ef16a..5d8df43eb2 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -268,7 +268,7 @@ def sentry_patched_popen_init( span: "Union[Span, StreamedSpan]" if span_streaming: span = sentry_sdk.traces.start_span( - name=OP.SUBPROCESS if description is None else description, + name=description, attributes={ "sentry.op": OP.SUBPROCESS, "sentry.origin": "auto.subprocess.stdlib.subprocess", From 347101f6249ff05b02814f062eaf205e0de60258 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 29 Apr 2026 08:53:29 +0200 Subject: [PATCH 26/26] fix code source tests --- tests/integrations/stdlib/test_httplib.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 763b527045..bc090263e1 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -842,7 +842,9 @@ def add_http_request_source_with_pinned_timestamps(span): if span_streaming: span._start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) span._timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) - return add_http_request_source(span) + result = add_http_request_source(span) + span._timestamp = None + return result else: span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) span.timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) @@ -914,7 +916,9 @@ def add_http_request_source_with_pinned_timestamps(span): if span_streaming: span._start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) span._timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) - return add_http_request_source(span) + result = add_http_request_source(span) + span._timestamp = None + return result else: span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) span.timestamp = datetime.datetime(2024, 1, 1, microsecond=100001)